Compare commits

..
Author SHA1 Message Date
bflatastic a64a5a9911 UI: Classic colour scheme (User Interface › Color Scheme)
A second colour scheme next to Modern: the neutral greys and blue accent of
earlier versions, on the same cards, corners and layout. Picked in
Preferences › User Interface › Color Scheme (pref uiColorScheme:
modern|classic); Accent Color is dimmed while Classic is chosen, which has
its own blue.
2026-10-16 14:29:33 +07:00
skylar taylor-barrick ae6dc17ffe Merge pull request #1184 from dylanmtaylor/refactor/dry-github-actions
ci: refactor workflows to use storytold/craft-common-actions and add nix check
2026-10-15 21:46:35 -06:00
skylartaylor 2fa10c957c ci: retain VectorCraft package and release checks 2026-10-16 05:43:40 +02:00
BFlat deff5b8f42 Merge pull request #2339 from storytold/readme/screenshots
README: new screenshots, with 3D Revolve and Dimension tool showcases
2026-10-16 09:57:55 +07:00
bflatastic c334aa7e66 README: new screenshots of the current interface, with 3D Revolve and Dimension tool showcases
Seven app screenshots retaken at 2240x1400 (UI at 1.6x), two of them new: a 3D still life with the Revolve dialog (examples/still-life.vectorcraft) and a bracket drawing with linked dimensions (examples/bracket-drawing.vectorcraft). The still life also joins Made in VectorCraft. The older examples are saved again in the current native format; they render the same.
2026-10-16 09:53:12 +07:00
BFlat 3f33e854d4 Merge pull request #2331 from storytold/ui/restyle
UI: restyled interface
2026-10-16 09:11:04 +07:00
bflatastic 257217a2e3 UI: restyled interface with a choice of accent colours 2026-10-16 09:00:14 +07:00
Dylan M. Taylor f08f3619e3 ci: refactor workflows to use storytold/craft-common-actions and add nix check 2026-10-15 21:38:50 -04:00
BFlat 8770d2a96c Merge pull request #1902 from Messhias/feat/g12-layers-collect-export
G12: Collect for Export on Layers (≡ and row menu)
2026-10-16 06:37:53 +07:00
BFlat c64c0120f5 Merge pull request #1918 from sanyamsingh001/codex/r65-guide-origin
G1: align guide snapping and readouts with ruler origin
2026-10-16 06:37:36 +07:00
BFlat 40fe3154ee Merge pull request #1912 from p0lyg0n/i18n/ja-swatches
i18n: the Swatches panel shows the default swatches' names in Japanese
2026-10-16 06:37:17 +07:00
BFlat a812bb7dcc Merge pull request #1900 from imachus/pr/ja-whitespace-fallback
Type: whitespace the primary font doesn't map takes the fallback face too (#1844)
2026-10-16 06:36:58 +07:00
BFlat 756ec5c5e3 Merge pull request #2281 from limackk/fix/round-corners-curve-line
Effects: Round Corners also rounds corners where a curve meets a line
2026-10-16 06:36:40 +07:00
BFlat 0bd4ce2260 Merge pull request #2280 from guillehr2/fix/transform-copies-symbol-instances-and-reach
effects: a Transform effect copies a group's symbol instances too, and stops image copies at MAX_REACH
2026-10-16 06:36:13 +07:00
BFlat 06fdeeef6c Merge pull request #2283 from marcoarnulfo/fix/svg-mask-type-alpha
SVG import: an alpha mask counts by its art's alpha, not its luminance
2026-10-16 06:35:54 +07:00
BFlat 9da787f951 Merge pull request #1888 from dotCooCoo/fix/brush-art-own-stroke-outlines
Render: the strokes of brush art are no longer drawn with old outlines
2026-10-16 06:35:36 +07:00
BFlat 2853d78e66 Merge pull request #1889 from dotCooCoo/fix/commands-with-nested-ids
Commands: ids leave out an object listed with a group that holds it, as the selection does
2026-10-16 06:35:21 +07:00
BFlat 3352e02a2b Merge pull request #2301 from dotCooCoo/feat/glass-load-texture
Effects: Glass loads its texture from an image file
2026-10-16 06:35:05 +07:00
BFlat 915a172b9a Merge pull request #2288 from dotCooCoo/feat/unit-fields-percent-arithmetic
Fields: unit fields take % in arithmetic, as in 3cm*50% and 50pt+25%
2026-10-16 06:34:49 +07:00
BFlat 59c872baaa Merge pull request #2290 from dotCooCoo/fix/transform-effect-copies-stay-type
Export: type copied by a Transform effect that only moves it stays type
2026-10-16 06:34:26 +07:00
BFlat 89a7992c98 Merge pull request #2289 from dotCooCoo/test/pdf-fuzz-unparseable-resources
Tests: the PDF import fuzz covers values the reader can't parse and functions inside other objects
2026-10-16 06:34:09 +07:00
BFlat 15819868a2 Merge pull request #2291 from dotCooCoo/fix/eps-cmyk-images-keep-samples
EPS import: CMYK images keep their ink amounts
2026-10-16 06:33:53 +07:00
BFlat e41f7818d6 Merge pull request #2293 from dotCooCoo/fix/ai-one-bit-color-images
.ai import: 1-bit RGB and CMYK images in the editing data open in their colors
2026-10-16 06:33:36 +07:00
BFlat 7316cf7e39 Merge pull request #2298 from dotCooCoo/fix/windows-start-after-graphics-driver-crash
Windows: after a graphics driver crash, the next start leaves that adapter out
2026-10-16 06:33:20 +07:00
BFlat 114960482e Merge pull request #2299 from dotCooCoo/feat/pdf-ai-file-info
PDF/.ai import: PDF and .ai files open with the File Info they carry
2026-10-16 06:33:04 +07:00
BFlat 23cfe935db Merge pull request #2303 from dotCooCoo/fix/removed-fonts-leave-the-list
Fonts: a removed font that type used leaves the list at the next font scan
2026-10-16 06:32:41 +07:00
Fabio William Conceição b04e3ac366 G12: Collect for Export from Layers panel and layer ids
Layers ≡ and row menus collect art via assets.add; a layer id expands to the art under it (including sublayers) so MCP and the panel match Object › Collect for Export.
2026-10-16 06:16:36 +07:00
sanyamsingh001andCodex 60c8b8f4e2 G1: align guide snapping and readouts with ruler origin
Co-authored-by: Codex <[email protected]>
2026-10-16 06:16:36 +07:00
Hiroshi InukaiandClaude Opus 5.5 75ee324e6c i18n: the Swatches panel shows the default swatches' names in Japanese
The list showed [None], [Registration] and the default swatches' names in
English. Names the app gives (the default swatches and colour groups) now
show in the UI's language where it has a `swatch` row for them; documents
keep the names as they are, and a name the user gave shows as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
imachusandClaude Opus 5.5 4265c2f301 Type: whitespace the primary font doesn't map takes the fallback face too (#1844)
An ideographic space in a face without U+3000 was shaped with that face
and could set at zero width, removing an indent. Only what the layout
handles itself (tab, line and paragraph breaks) stays with the primary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
Kamil KwiatkowskiandClaude Opus 5.5 a14942de9c Effects: Round Corners also rounds corners where a curve meets a line
A corner whose anchor carries a handle (where a curve meets a line, or two
curves meet) was left sharp. It now gets Live Corners' round cut
(geom::corners, #1144), reaching `radius` along each side and trimming a
curved side. Corners between handle-less anchors are rounded exactly as
before, and a closed path still starts where its first corner's rounding
starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
Guillem Hermida d05ccffebc effects: a Transform effect copies a group's symbol instances too, and stops image copies at MAX_REACH
Symbol instances are placed through a transform as images are, so the
Transform effect now moves and copies both (transform_placed), sharing one
copy budget (MAX_COPY_OBJECTS). Copies of either stop before one reaches
past MAX_REACH, as a path's do (distort::within_reach).

Adds end-to-end export tests for a group's image copies in svg and pdf.
2026-10-16 06:16:35 +07:00
Marco Arnulfo 789a151ff9 SVG import: an alpha mask counts by its art's alpha, not its luminance (fixes #2210) 2026-10-16 06:16:35 +07:00
dotCooCoo a8e612d3e2 Render: the strokes of brush art are no longer drawn with old outlines
While the renderer drew a brushed path on its fast path, it drew the
pieces of brush art with that path still set as the object being
drawn. cached_stroke and fill_path_gradient then kept the outlines and
gradient slices of the pieces' strokes under the brushed path and the
address of each piece's stroke. The entries held the brushed path and
not the pieces, so a piece made later could take the address of an
earlier piece's stroke and be drawn with that piece's outline. The
instances of a symbol and the copies of a live repeat replace each
other's brush art, so it is made again for each of them in every
frame, and every brushed path gets new brush art when the document's
brushes change. In a test, a renderer drawing three instances of a
symbol whose art holds a path brushed with a scatter brush of a
stroked square drew frames that differed from a new renderer's in 316
to 1,140 pixels.

The two caches now keep only the strokes of the object being drawn. With
vectorcraft-cli bench at 2880x1800 on 8 threads, 2,000 instances of a
symbol of eight brushed paths took 203.4 ms a frame on main and 155.9 ms
with this change. 2,000 groups of the same paths, for which main keeps
the brush art and the outlines of its strokes between frames, took 133.7
ms on main and 156.9 ms with this change. The peak working set of a
bench run at 2880x1800 went from 609 MiB to 127 MiB on the instances and
from 331 MiB to 210 MiB on the groups.
2026-10-16 06:16:35 +07:00
dotCooCoo 67a278b053 Commands: ids leave out an object listed with a group that holds it, as the selection does
Commands that read the selection through edit::selected_roots leave
out an object whose group is also selected (edit::roots_of). Fourteen
places that read `ids` kept an object listed together with its group.
Given ids that list an object and a group that holds it, Make Repeat
failed with "no such object", New Symbol failed or stored the object
twice in the symbol, and object.transform moved it twice (20 pt for a
10 pt move).

These places now pass explicit ids through edit::outermost, which
drops an id whose ancestor is also listed, with the filter that
roots_of runs on the selection (now edit::without_inner). Nothing else
is dropped or replaced: unlike roots_of, it keeps layers and
compound-path members. A list without nesting gives the same result as
before. Commands that take both ids and the selection as listed, such
as paint.setFill without item, are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
bflatastic a6f56caa0e Glass Load Texture: el, fi, pt-br and sv rows 2026-10-16 06:16:35 +07:00
dotCooCoo 7e9d0be723 Fields: unit fields take % in arithmetic, as in 3cm*50% and 50pt+25%
Unit fields and length strings read a percentage operand as a share of
a value. After * or /, the percentage scales the term before it:
3cm*50% is 1.5 cm and 3cm/50% is 6 cm. After + or -, it adds or takes
away that share of everything before it: 50pt+25% is 62.5 pt and
100pt+20pt-50% is 60 pt. Before this change these expressions read as
nothing, and the field kept its old value.

The parser now splits a sum at its last + or -, so a percentage applies
to the whole sum before it. Text without % gives the same values as
before, apart from float rounding. A percentage on its own (50%) still
reads as nothing, and percent and degree fields still read % as their
suffix.

Command length strings use the same parser: file.new's width and
height, the distance params and the length preferences.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo da43c662e0 Export: type copied by a Transform effect that only moves it stays type
Exports bake live effects into plain art, and a Transform effect on
type or on its group or layer turned the type into glyph outlines, even
when the effect only moved it or made moved copies. A PDF reader could
not select or search that text.

The export bake now keeps such type as type, moved into each place the
effect puts its outlines. It does this for Transform effects without
scale, rotation, reflection or Random, for type whose characters are in
solid colors without underline or strikethrough, and that has no fills
or strokes of its own, inline graphics or perspective, and when the
copies do not overlap. Type under such an effect on its group or layer
also needs no transparency or effects of its own, and the group or
layer needs no fill, stroke or Pathfinder effect of its own. The export
still outlines other type under geometry effects, and the canvas and
Expand Appearance still draw outlines.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo 146c43267a Tests: the PDF import fuzz covers values the reader can't parse and functions inside other objects
A new import fuzz test, pdf_resources_the_reader_cannot_parse_never_panic,
imports a page whose resources hold:

- a value the reader can't parse (/#zz, 12-.5) or one it reads (/Fine)
  in the color space dictionary, before a Separation space the page
  paints in, and in the font dictionary, before the font of its text;
- a calculator function written as the shading's function, or inside an
  array or a dictionary in the page's /Properties: a plain one, one with
  Columns 0, one with a huge Columns, or one of 257 MiB of deflated
  spaces.

The import must not panic. The file opens with its art, or it is refused
with "would decode to more than 256 MB" when the function is not plain.

With the nesting check, the font scan or the ink scan reading
Dict::entries again, the test fails on a value the reader can't parse.
With the stream check made on the file's objects alone, the test fails
on every function that is not plain: the file opens instead of being
refused.

The calculator helper of pdf_streams_the_reader_would_fail_on_are_refused
is now a function of the file, and the function of spaces is made once.
2026-10-16 06:16:35 +07:00
dotCooCoo 3b327d9512 EPS import: CMYK images keep their ink amounts
The PostScript interpreter that reads EPS and PostScript files turned
the samples of a CMYK image into display RGB and stored an RGBA PNG,
so the image lost its ink amounts. The DCTDecode filter decoded a CMYK
JPEG to RGB, so it lost its ink amounts too. The PDF importer keeps
the ink amounts of 8- and 16-bit CMYK images (in a CMYK TIFF, or the
CMYK JPEG itself).

A CMYK image (image or colorimage in DeviceCMYK, CIEBasedDEFG or a
four-component ICCBased space) now keeps its samples in a CMYK TIFF
(ImageBlob::cmyk_tiff), read through its Decode array, at every bit
depth the interpreter reads. A CMYK JPEG gives its samples as stored,
and the image's Decode array reads them: [1 0 1 0 1 0 1 0] undoes the
inverted samples of Adobe's CMYK JPEGs. The pixels that a MaskColor
or an ImageType 3 mask hides become a grayscale opacity mask over the
image, as in PDF import. A mask that hides nothing adds no mask. Gray,
RGB, indexed and spot color images and image masks stay RGBA PNGs.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo 28cb75cc59 .ai import: 1-bit RGB and CMYK images in the editing data open in their colors
The editing data reader sized each row of a 1-bit image as width ×
channels bits, padded to a whole byte, but read one bit per pixel and
made the pixel gray from it. A 1-bit RGB or CMYK image came out as a
black and white image of the wrong bits. When that made the layers
differ from the page by more than 5%, the file opened from its PDF
part instead, with a warning.

The reader now takes channel c of pixel x from bit x × channels + c of
the row, a set bit being the channel at full strength, and converts
the samples as it converts 8-bit ones. 1-bit gray images read as
before.
2026-10-16 06:16:35 +07:00
bflatastic 50111c87fd Graphics start record: el, fi and sv rows 2026-10-16 06:16:35 +07:00
Kamil KwiatkowskiandClaude Opus 5.5 716a968e7a Contributors: add limackk (Kamil Kwiatkowski)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:16:35 +07:00
dotCooCoo c586cecf75 Effects: Glass loads its texture from an image file
Effect › Distort › Glass… offered only its four built-in textures. The
reference app's Texture menu also has Load Texture, which takes an
image file as the glass surface.

The Glass dialog now has a Load Texture… button. It reads a PNG, JPEG,
GIF, WebP, TIFF, BMP, PSD or PSB file within the pixel allowance the
renderer draws images in, and the Texture menu gains Loaded Texture.
The effect stores the image's gray levels in `textureImage` as a
grayscale PNG in base64, so the document saves the texture with the
effect, and copies of the effect carry it. effect.apply and
effect.setParams take `textureImage` as {path}, {name, dataBase64} or
the stored string; any other value is an error.

The texture lies one image pixel per point at Scaling 100%, centered on
the object and repeated. Each pixel's luma is the surface's height,
from 0 for black to 1 pt for white, and a transparent pixel counts as
middle gray. A Glass without `textureImage` draws and saves as before.

Docs: docs/mcp.md (Glass textures), docs/effects-parity.md,
docs/gaps.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00
dotCooCoo 261b64251d PDF/.ai import: PDF and .ai files open with the File Info they carry
A PDF, PDF-compatible .ai or .ait opened from its pages had an empty
File Info. Nothing read the file's document information
dictionary or its XMP packet, so file.info reported no author,
description or keywords, even for a PDF that VectorCraft exported.

vectorcraft_pdf::file_info reads both. The author, description,
keywords, copyright status, notice and URL and the created and modified
dates come from the XMP packet (dc:creator, dc:description, dc:subject
or pdf:Keywords, xmpRights:Marked, dc:rights, xmpRights:WebStatement,
xmp:CreateDate, xmp:ModifyDate), else from the dictionary's Author,
Subject, Keywords, CreationDate and ModDate. document.open,
file.newFromTemplate and file.revert put each value through file.info's
checks and leave out a value that file.info refuses. A packet that is
not well-formed XML leaves the dictionary's values. File Info does not
set the title, and a file restored from the document it carries keeps
that document's File Info. vectorcraft-pdf depends on roxmltree 0.21,
which usvg already brings into Cargo.lock.

Docs: docs/mcp.md (File Info), docs/file-format-parity.md,
docs/target-app-parity.md.
2026-10-16 06:16:34 +07:00
dotCooCoo d1089270ba Fonts: a removed font that type used leaves the list at the next font scan
When type had used a font, removing the font while the app ran left
it in the font menus, and the type kept drawing in it until the app
restarted. The font list is the loaded faces plus the scanned catalog,
and a scan replaced the catalog but kept every loaded face.

A scan now drops each loaded face whose file it did not catalog, with
the face's cached outlines. A family with no face left leaves the font
lists, and type set in it lays out in the fallback font and is
reported missing, as when a document opens without the font. When the
font is installed again, the next scan lists it and the type draws in
it again. Bundled fonts and fonts added as bytes have no file and
stay.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:34 +07:00
dotCooCoo ee5b0938aa Windows: after a graphics driver crash, the next start leaves that adapter out
When a graphics driver crashed the process while the window started,
as Intel's Direct3D 12 driver (igd12umd64.dll) did on an Intel UHD
Graphics 605 (#2243), the next start picked the same adapter again. The
restart without a failing adapter and the first-frame watch run inside
the process, so the crash ended them along with it.

Each start now writes graphics-start.json in the log folder before the
graphics start, adds the adapter once one is picked, and clears both
when the first frame reaches the screen or the app ends without
crashing. A start that finds an earlier start still recorded there
leaves that adapter out, as a restart does, logs it and shows a note in
the status bar. When the crash came while the adapters were being
listed (wgpu's Direct3D 12 backend creates a device on each GPU to list
it), the next starts list OpenGL's adapters alone, except on macOS. The
adapters stay left out until the app version or the Graphics Processor
preference changes, a start shows its first frame on one, or no adapter
is left. WGPU_BACKEND and WGPU_ADAPTER_NAME choose regardless of the
record.

Docs: docs/development.md, docs/target-app-parity.md.
2026-10-16 06:16:34 +07:00
BFlat 76d5ca8bfa Merge pull request #2304 from storytold/docs/2026-10-16-landings
Docs: the landings of 2026-10-15/16 in the ROADMAP log, and the new languages
2026-10-16 05:59:53 +07:00
bflatastic 080d90ed7f Docs: the landings of 2026-10-15/16 in the ROADMAP log, languages in the ROADMAP, README and localization parity 2026-10-16 05:56:27 +07:00
BFlat 68887a21c5 Merge pull request #1994 from Janiz-zzz/finnish-interface
Add Finnish interface language
2026-10-16 05:53:29 +07:00
BFlat 50152c9941 Merge pull request #2076 from Camthalion77/feature/greek-localization
Add Greek (el) localization
2026-10-16 05:53:10 +07:00
BFlat f29390c590 Merge pull request #2139 from kler/swedish-ui
i18n: Swedish (sv) interface translation
2026-10-16 05:52:52 +07:00
BFlat 872e86d3f6 Merge pull request #2002 from tastyhub/flatpak-font-manager-fonts
Flatpak: fonts a font manager activates into ~/.fonts are visible
2026-10-16 05:52:34 +07:00
BFlat d6e4366885 Merge pull request #1999 from tydude001/rpm-glibc-floor-libm
packaging: RPM glibc floor names libm's GLIBC_2.35, so RHEL 9 refuses it
2026-10-16 05:52:18 +07:00
bflatastic 094393959d i18n (fi): rows for the strings main added since the catalog was written
English keeps its own name, as the Language menu shows every language in its own name.
2026-10-16 05:44:50 +07:00
bflatastic ea164c1543 i18n (el): a complete catalog, with rows for the strings main added since it was written
English keeps its own name, as the Language menu shows every language in its own name.
2026-10-16 05:44:50 +07:00
Janiz-zzzandClaude Opus 5.5 280de29c86 Add Finnish interface language
Adds a Finnish (Suomi, `fi`) catalog with 4,770 entries, written in
sentence case from the English labels with ordinary Finnish
vector-illustration vocabulary. It covers menus, `tl!` literals and the
668 `@msg` status and error messages: `fi` is in KEEPS_MENU_NAMES and
COMPLETE_MESSAGES. Like the other complete catalogs, it leaves the
product, workspace and perspective preset names in English.

Plurals are one/other with the partitive singular after any number but
1 (1 taso, 2 tasoa). Any fi-* locale selects Finnish; sv-FI does not.

Tests: `cargo test -p vectorcraft-ui-egui --lib i18n` passes (47),
including the new finnish_reads_as_finnish and the bundled-font glyph
check.

Clean-room, AI-assisted (Claude Code) translation; a native speaker
checked it in the running app, but it has not been proofread line by
line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:44:50 +07:00
bflatastic b68c467b45 i18n (sv): rows for the strings main added since the catalog was written 2026-10-16 05:37:33 +07:00
Γιώργος Αθενάκης afe0239600 Add Greek (el) localization 2026-10-16 05:37:33 +07:00
Patrik LermonandClaude Opus 5.5 a91129e422 Swedish interface translation (sv)
Adds Swedish (Svenska) as an interface language: 4770 strings, written from the English source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:27:45 +07:00
Ryan WalkerandClaude Opus 5.5 ced45ec157 Flatpak: fonts a font manager activates into ~/.fonts are visible
Flatpak opens only /usr/share/fonts and ~/.local/share/fonts to a sandbox, so the
fonts a font manager such as FontBase activates into ~/.fonts were missing from the
font list while native apps showed them. Granted read-only: VectorCraft only reads
fonts.

Verified on the 0.11.0 release bundle by applying the same grant as a user override
(`flatpak override --user --filesystem=~/.fonts:ro`): in a fresh sandbox the FontBase
families went from none to all 167, `fc-match` resolved a family that had been missing
from the app, and the font then appeared in the font list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:26:32 +07:00
tydude001andClaude Opus 5.5 e12a670c40 packaging: RPM glibc floor names libm's GLIBC_2.35, so RHEL 9 refuses it
#1290 (from #1275) declared the glibc floor as `libc.so.6(GLIBC_2.35)(64bit)`. That doesn't stop
RHEL 9: its current glibc, `2.34-283.el9_8`, provides `libc.so.6(GLIBC_2.35)` through backports. On
Rocky Linux 9, dnf upgrades glibc to that build, installs VectorCraft 0.10.0, and `vectorcraft-cli`
then fails with ``/lib64/libm.so.6: version `GLIBC_2.35' not found``. The binaries' only GLIBC_2.35
symbols are `hypot` and `hypotf`, from libm, and RHEL 9 doesn't provide `libm.so.6(GLIBC_2.35)`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:26:32 +07:00
BFlat 82db3344bf Merge pull request #1928 from Messhias/feat/g16-pt-br-complete
G16: complete Brazilian Portuguese (pt-br) interface
2026-10-16 05:13:53 +07:00
BFlat 96ce65954e Merge pull request #1943 from Messhias/feat/g6-mcp-dataset-docs
G6: document dataset commands and command.batch in MCP guide
2026-10-16 05:13:38 +07:00
BFlat 80178d9902 Merge pull request #1938 from Messhias/feat/g6-actions-batch
G6: action.batch (File › Automate › Batch)
2026-10-16 05:13:23 +07:00
BFlat 28fe30ce58 Merge pull request #1941 from Messhias/feat/g6-graph-variables
G6: graph variables (CSV data merge)
2026-10-16 05:13:07 +07:00
BFlat 5f7f9f6d37 Merge pull request #1929 from Messhias/feat/g6-dataset-csv
G6: dataset CSV import/export
2026-10-16 05:12:50 +07:00
BFlat 1060bc6bed Merge pull request #1908 from Messhias/feat/g6-image-variables
G6: image variables (VariableKind::Image)
2026-10-16 05:12:35 +07:00
BFlat 1ea496a04f Merge pull request #1959 from Messhias/feat/symbol-registration
G5: Symbol Options Registration 9-point grid
2026-10-16 05:12:19 +07:00
BFlat a302783f32 Merge pull request #2053 from sanyamsingh001/codex/r76-warp-options
G1: Add Warp Options controls for live effects
2026-10-16 05:12:08 +07:00
BFlat bb66ec13e2 Merge pull request #1961 from Messhias/feat/assets-export-seam-test
G11: Asset Export seam regression and supersampled thumbs
2026-10-16 05:11:50 +07:00
BFlat 9ab58ba536 Merge pull request #1922 from sanyamsingh001/codex/r68-guide-escape
G1: Cancel existing-guide drags with Escape
2026-10-16 05:11:32 +07:00
BFlat 756d05d7ca Merge pull request #1982 from sanyamsingh001/codex/r76-open-anchor-delete
G11: Preserve gaps when deleting open-path anchors
2026-10-16 05:11:16 +07:00
BFlat 6dc60a6697 Merge pull request #1921 from sanyamsingh001/codex/r65-artboard-document-switch
G1: Keep the displayed artboard selected after switching documents
2026-10-16 05:11:01 +07:00
BFlat 5d3d28d56f Merge pull request #2095 from switch87/feat/1763-intertwine
G13: Object › Intertwine: Make, Edit, Release and the Intertwine tool (#1763)
2026-10-16 05:10:43 +07:00
BFlat 832ca236d3 Merge pull request #1936 from marcoarnulfo/fix/macos-title-bar-double-click
Title bar: a double-click on the macOS app bar zooms the window
2026-10-16 05:10:27 +07:00
BFlat 82d5e31be4 Merge pull request #2125 from switch87/feat/2113-batch-draw
MCP command_batch: draw_path and draw_shape run as steps (#2113)
2026-10-16 05:10:12 +07:00
BFlat 3fb463052e Merge pull request #2124 from switch87/feat/2112-draw-options
MCP draw_path / draw_shape: dash, cap, join, opacity, name and effects (#2112)
2026-10-16 05:09:57 +07:00
BFlat 65c06f3a57 Merge pull request #2128 from switch87/feat/2127-gradient-span
paint.setFill / setStroke: one gradient across the targets (span: "selection") (#2127)
2026-10-16 05:09:42 +07:00
BFlat 93e92d8a91 Merge pull request #2137 from switch87/feat/2130-ids-select-first
Selection-only commands take ids: the objects are selected first (#2130)
2026-10-16 05:09:27 +07:00
BFlat 5e2dd693ae Merge pull request #1962 from marcoarnulfo/fix/pdfx-icc-once
PDF export: the output intent shares the stream of the profile that tags the colours
2026-10-16 05:09:12 +07:00
BFlat dcb759011e Merge pull request #2121 from switch87/fix/2110-gui-help
vectorcraft --help prints the usage before the app starts (#2110)
2026-10-16 05:08:56 +07:00
BFlat 13a1b60d28 Merge pull request #1947 from marcoarnulfo/fix/text-center-shortcut-and-autosize
Text: Ctrl+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size
2026-10-16 05:08:41 +07:00
BFlat 2c1bb5f839 Merge pull request #1981 from marcoarnulfo/fix/maximize-work-area
Windows: a maximized window stays clear of the taskbar
2026-10-16 05:08:26 +07:00
bflatastic f7ab3277e0 Docs: one row per area and milestone after the stacked doc edits (#1928) 2026-10-16 04:55:02 +07:00
bflatastic 93d6411cce i18n: Brazilian Portuguese rows for the messages added since (#1928) 2026-10-16 04:55:02 +07:00
Fabio William ConceiçãoandCursor 4614821372 docs: note pt-br completion in target-app-parity status
Co-authored-by: Cursor <[email protected]>
2026-10-16 04:55:02 +07:00
Fabio William Conceição 671136fa66 G16: complete Brazilian Portuguese (pt-br) interface
Finish the partial pt-br catalog so menus, tl! literals and status/error
messages stay translated under the completeness tests (#1914).
2026-10-16 04:55:02 +07:00
bflatastic 2ce3411493 Docs: one Variables and one Data sets entry in the MCP guide (#1943) 2026-10-16 04:55:01 +07:00
bflatastic 8412b4658f i18n: catalog rows for the new strings (#1938) 2026-10-16 04:55:01 +07:00
Fabio William ConceiçãoandCursor 7ca2763587 G6: document dataset commands and command.batch in MCP guide
Spell out dataset.list/capture/update/delete/rename and distinguish the MCP
command_batch tool from the engine command.batch Actions play.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:55:01 +07:00
bflatastic 08dde8783e Actions: Batch… stays in the Actions panel menu, as the reference app has it (#1938) 2026-10-16 04:55:01 +07:00
bflatastic b3e484702f i18n: catalog rows for the new strings (#1941) 2026-10-16 04:50:07 +07:00
bflatastic 799f94ecf1 i18n: catalog rows for the new strings (#1929) 2026-10-16 04:50:07 +07:00
bflatastic a38c824066 Variables: an image variable keeps the object's bounds with the Placement Options API on main (#1908) 2026-10-16 04:50:07 +07:00
Fabio William ConceiçãoandCursor 0fe70314b8 G6: action.batch for File › Automate › Batch
Play an action on every file in a folder or every data set, with destination
and Override Open/Save/Export. Actions panel and File › Automate › Batch…
open the dialog; new strings in pt-br.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:50:07 +07:00
bflatastic 568008dd47 Variables: graph values go through the dataset CSV too (#1941) 2026-10-16 04:50:07 +07:00
bflatastic 192a671d4f Variables: dataset CSV keeps line breaks in quoted cells and reads a file only up to its cap (#1929) 2026-10-16 04:50:07 +07:00
Fabio William ConceiçãoandCursor dc390aba37 G6: graph variables (CSV data merge)
VariableKind::Graph binds a graph root; dataset rows supply CSV as graph.setData.
Make Graph Dynamic, Variables dialog, capture/apply, and pt-br for the new strings.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:50:07 +07:00
Fabio William Conceição 5ef090a0c2 G6: dataset CSV import/export with PT-BR UI labels
Load and save variable datasets as CSV for data-merge pipelines, and
translate the new Variables panel commands in Brazilian Portuguese.

Co-authored-by: Cursor <[email protected]>

# Conflicts:
#	docs/target-app-parity.md
2026-10-16 04:50:07 +07:00
bflatastic 699856f7d5 i18n: catalog rows for the new strings (#1908) 2026-10-16 04:45:08 +07:00
Fabio William Conceição 41c8bd2a2d G6: image variables swap linked files and keep bounds
Add VariableKind::Image with path values on datasets, makeImageDynamic,
panel/dialog UI, and MCP docs so data merge can drive placed images.
2026-10-16 04:45:06 +07:00
bflatastic c8310edea1 i18n: Bulgarian row for Symbol Options Registration (#1959) 2026-10-16 04:45:04 +07:00
sanyamsingh001andCodex 848d902354 G1: Add Warp Options controls for live effects
Expose existing Warp styles, orientation and percentage controls through the shared live effect dialog while preserving preview history and effect targets. Keep ordinary Enter owned by focused non-searchable combo controls.

Co-authored-by: Codex <[email protected]>
2026-10-16 04:45:03 +07:00
Fabio William ConceiçãoandCursor 80090e7623 G5: Symbol Options Registration 9-point grid
Store the registration point with the symbol and place instances so that point lands on (x, y). Symbol Options exposes the same 9-point control as Transform.

Co-authored-by: Cursor <[email protected]>
2026-10-16 04:45:03 +07:00
Fabio William Conceição ed6d474042 G11: Asset Export seam regression and supersampled thumbs
assets.export of abutting fills must leave no ghost edge (#983 / #1037); panel thumbnails use the same Art Optimized supersample. Progress docs mark the gap closed.
2026-10-16 04:45:01 +07:00
sanyamsingh001andCodex 74573d6bad G1: Cancel existing-guide drags with Escape
Co-authored-by: Codex <[email protected]>
2026-10-16 04:44:59 +07:00
sanyamsingh001andCodex 67f9d134ed G11: Preserve gaps when deleting open-path anchors
Keep surviving drawable runs separate when Delete or Backspace removes an open-path anchor. Preserve appearance, selection and one-step Undo.

Co-authored-by: Codex <[email protected]>
2026-10-16 04:44:58 +07:00
sanyamsingh001andCodex a1b3c4804f G1: Keep the displayed artboard selected after switching documents
Co-authored-by: Codex <[email protected]>
2026-10-16 04:44:56 +07:00
bflatastic 9eddeeb952 Legacy .ai export writes Intertwine groups as their art (#2095) 2026-10-16 04:44:54 +07:00
bflatastic 5e75d26d29 i18n: Bulgarian rows for Intertwine (#2095) 2026-10-16 04:44:54 +07:00
Gert Pellin 3ae6cf28e4 G13: Intertwine tool, Object › Intertwine › Edit, menu and translations (#1763)
Make and Edit choose the Intertwine tool: hovering highlights the overlap
area under the pointer, a click swaps which object is on top there, and a
path drawn around overlaps (Shift: a rectangle) arranges every overlap
inside it; Esc or Enter goes back to the tool chosen before. The overlap
faces move to vectorcraft-tools, shared by the tool and the command.

Clicks inside a raised area pick the member drawn on top there, the
Object menu gets the Intertwine submenu, and the new labels and the
predicate message are translated in the complete catalogs. docs/gaps.md
records what landed under G13.
2026-10-16 04:44:53 +07:00
Marco Arnulfo 20325b1a89 Title bar: a double-click on the macOS app bar zooms the window (fixes #1932) 2026-10-16 04:44:52 +07:00
Gert Pellin ba254a7481 MCP command_batch: draw_path and draw_shape run as steps (#2113)
A step whose id is draw_path or draw_shape runs that tool with its params,
checked as tools/call checks arguments; its result is the tool's, so later
steps can use "$N.id". The reply lists the ids the drawing steps made
under created. The unknown-argument check is shared with tools/call.
2026-10-16 04:44:52 +07:00
Gert Pellin 49fcfbd145 MCP draw_path / draw_shape: dash, cap, join, opacity, name and effects (#2112)
The drawing tools style the object they draw in the same call: dash, cap
and join go to the stroke.set that strokeWidth already sends, opacity and
name to object.setProps, and effects are applied in order as apply_effect
does. The options are checked (effect ids against effect.list) before
anything is drawn, so a bad one leaves no half-styled object behind.
2026-10-16 04:44:52 +07:00
bflatastic 5fc7072600 i18n: catalog rows for the new strings (#2128) 2026-10-16 04:44:52 +07:00
Gert Pellin 74c391c7ba G13: Object › Intertwine: Make, Release and arranging overlap areas (#1763)
An Intertwine group keeps its objects as they are, in their own stacking
order; each member can be drawn over the others in an area of its own.
The group draws, hits, saves and exports through its live expansion, so the
members stay editable and Release gives them back unchanged.

object.intertwine.arrange picks an overlap area by a click point or by a
closed path drawn around overlaps and toggles which member is on top
there, or brings a member to the front or sends it to the back where three
or more objects overlap.
2026-10-16 04:44:52 +07:00
Gert Pellin e7aacec852 Selection-only commands take ids: the objects are selected first (#2130)
object.group, ungroup, the clipping mask and compound path Make/Release,
the Arrange commands, lock, hide, edit.duplicate, object.move, rotate,
scale, reflect, shear and the Pathfinder commands acted only on the
selection, so a script had to send select.set first. Given ids, they now
select those objects (select.set, with its checks) and run as on the
selection, in the same undo step; without ids nothing changes.
2026-10-16 04:44:50 +07:00
Gert Pellin 736abd811d paint.setFill / setStroke: span "selection" places one gradient across the targets (#2127)
A gradient given to several objects fits each one's own bounds. With
span: "selection", a linear or radial gradient without its own vector is
placed once on the union of the targets' bounds, at its angle and aspect,
and every target gets that vector in document coordinates, so the ramp
runs on across them. A solid colour, a swatch, a freeform gradient or an
explicit start/end with it is refused before anything changes; the next
object drawn still fits the gradient to itself.
2026-10-16 04:44:50 +07:00
Gert Pellin 265552d5f4 Tests: a gradient spanning the selection (#2127) 2026-10-16 04:44:50 +07:00
bflatastic e6b28f0e80 Tests: read the PDF/X output intent's profile with the crate's own xref reader (#1962) 2026-10-16 04:44:31 +07:00
Gert Pellin e2df2e774c vectorcraft --help prints the usage before the app starts (#2110)
--help and -h used to be taken for a file to open: from a terminal, SSH or an
agent's shell the window opened, or the start hung or failed without a display.
They now print the desktop app's options and point to vectorcraft-cli for
headless work, exiting before logging, preferences or the window, as
--version does.
2026-10-16 04:44:31 +07:00
bflatastic 2843687ef4 Text: leave out the bottom-handle Auto Size double-click until its behaviour is settled (#1947) 2026-10-16 04:44:31 +07:00
Marco Arnulfo 32dbc4b641 PDF export: the output intent shares the stream of the profile that tags the colours (fixes #1954) 2026-10-16 04:44:31 +07:00
Marco Arnulfo a69371656b Text: Ctrl+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size (fixes #1940) 2026-10-16 04:44:29 +07:00
Marco Arnulfo 4b8a987445 Windows: a maximized window stays clear of the taskbar (fixes #1977) 2026-10-16 04:44:11 +07:00
BFlat 52b072873d Merge pull request #2024 from Muratv07/feature/move-art-and-guides-together
feat: move and copy ruler guides and art together when selected
2026-10-16 04:35:38 +07:00
BFlat 0c9ce426e7 Merge pull request #2027 from lcslrx/feat/text-bundled-fonts-feature
Text: a `bundled-fonts` feature (on by default) lets embedders that bring their own fonts leave ours out
2026-10-16 04:35:17 +07:00
BFlat 98220d1929 Merge pull request #2220 from lcslrx/feat/render-svg-without-text-features
Render, SVG: vello_cpu and usvg without their text features: one skrifa, read-fonts and harfrust fewer in the engine
2026-10-16 04:35:01 +07:00
BFlat c141d54dad Merge pull request #2111 from IanHollow/codex/vectorcraft-document-fonts
Text: make every supplied font family available to documents
2026-10-16 04:34:43 +07:00
BFlat f16f542b9b Merge pull request #1944 from yukincom/codex/mcp-vertical-text
G6: expose vertical writing in MCP add_text
2026-10-16 04:34:26 +07:00
BFlat 0db9d6f757 Merge pull request #1946 from yukincom/codex/vertical-punctuation-upright
G4: keep fullwidth punctuation upright without vertical alternates
2026-10-16 04:34:07 +07:00
BFlat a23a682960 Merge pull request #1949 from ham9zah/curvature-illustrator-geometry
Curvature: each three points follow the circle through them, curved ends (#1236, in part)
2026-10-16 04:33:47 +07:00
BFlat 2d1e09cf32 Merge pull request #1975 from el-j/feat/direct-selection-double-click-type
Canvas: double-clicking type with Direct Selection edits it, and Escape returns to originating tool
2026-10-16 04:33:28 +07:00
BFlat 1838a4e89a Merge pull request #2117 from wstock/feat/2000-hide-template
View › Hide Template (Cmd+Shift+W): template layers leave the canvas and come back (#2000)
2026-10-16 04:33:08 +07:00
BFlat d02802f391 Merge pull request #2085 from Silvertree2010/fix/2049-layers-panel-does-not-reveal-the
Reveal the active layer in the Layers panel after a canvas selection
2026-10-16 04:32:51 +07:00
BFlat 20525c9346 Merge pull request #2150 from AyberkA0/docinfo-path-length
Document Info: Objects gives the paths' total length (#1958)
2026-10-16 04:32:34 +07:00
BFlat 3ece6428b3 Merge pull request #2083 from fabian-co/fix/panel-menu-hover
Panel menus light up under the pointer, like the menu bar's
2026-10-16 04:32:17 +07:00
BFlat e67a9f558a Merge pull request #2082 from fabian-co/feat/swatches-follow-selection
Swatches panel follows the selection's swatch
2026-10-16 04:32:01 +07:00
BFlat a9d72d01b1 Merge pull request #2081 from fabian-co/feat/image-replace
Replace a selected image from the Control bar, with its own aspect ratio
2026-10-16 04:31:45 +07:00
BFlat 81e2c81890 Merge pull request #2080 from fabian-co/feat/recolor-library-swatches
Recolor Artwork: library colours apply as their swatches; import a library
2026-10-16 04:31:30 +07:00
BFlat fe5c78f6a5 Merge pull request #2079 from fabian-co/feat/recolor-shortcuts
Recolor Artwork from the Contextual Task Bar and the Control bar
2026-10-16 04:31:13 +07:00
BFlat 57a3cfab73 Merge pull request #1930 from turtwig/fix/glyphs-preview-zoom
Glyphs panel: zoom buttons resize the preview and the step is kept
2026-10-16 04:30:54 +07:00
BFlat 4dcb0900a6 Merge pull request #2265 from andeecollard/tests/shared-closed-boundaries
Image Trace: regression tests for shared boundaries in Abutting traces
2026-10-16 04:30:35 +07:00
BFlat 80be476b04 Merge pull request #2264 from andeecollard/fix/trace-grayscale-gradients
Image Trace: grayscale gradients use grey stops
2026-10-16 04:30:29 +07:00
BFlat cddc5a6848 Merge pull request #2263 from andeecollard/fix/trace-seam-guard
Image Trace: keep shared corners identical at nearby junctions
2026-10-16 04:30:23 +07:00
BFlat d6f6cb9b5f Merge pull request #2025 from andeecollard/fix/trace-snap-collapsed-loops
Image Trace: preserve thin closed outlines after straightening
2026-10-16 04:30:15 +07:00
BFlat 5aab51bf87 Merge pull request #2012 from andeecollard/fix/trace-small-closed-contours
Image Trace: retain tiny components and holes after fitting
2026-10-16 04:29:57 +07:00
BFlat 3bcce6593a Merge pull request #2144 from dotCooCoo/fix/twisted-walls-depth-order
3D and Materials: twisted walls and bevels are drawn back to front
2026-10-16 04:25:17 +07:00
BFlat 192ca0b8e6 Merge pull request #2180 from dotCooCoo/feat/scribble-separate-variations
Effects: Scribble has a Variation for Path Overlap, Curviness and Spacing, and stores 270° as -90°
2026-10-16 04:25:00 +07:00
BFlat b64c88b4d9 Merge pull request #2154 from dotCooCoo/feat/smart-blur-edge-modes
Effects: Smart Blur's Mode menu has Edge Only and Overlay Edge
2026-10-16 04:24:44 +07:00
BFlat 78c5973609 Merge pull request #1925 from dotCooCoo/feat/clipboard-flavors-off-ui-thread
Clipboard: Copy and Cut make the system clipboard's formats on the background thread
2026-10-16 04:24:28 +07:00
BFlat 99a6c06681 Merge pull request #2032 from dotCooCoo/fix/issue-1876
Artboards: the options icon and the panel menu open Artboard Options
2026-10-16 04:24:10 +07:00
BFlat c42b31f48a Merge pull request #2039 from dotCooCoo/fix/pathfinder-effect-keeps-type
Effects: Effect › Pathfinder uses the glyph outlines of type in the group
2026-10-16 04:23:53 +07:00
BFlat 4f794ea8db Merge pull request #2056 from dotCooCoo/feat/perf-deep-peak-working-set
CLI: perf --deep reports the peak working set on Windows and the peak resident memory on Linux
2026-10-16 04:23:37 +07:00
BFlat cc467d1ef4 Merge pull request #2063 from dotCooCoo/feat/plugin-geometry-follows-registry
Render: objects with plug-in effects follow the plug-ins installed
2026-10-16 04:23:18 +07:00
BFlat bfe6670539 Merge pull request #2064 from dotCooCoo/fix/fit-artboard-active
Artboards: Fit to Artwork Bounds and Fit to Selected Art resize the active artboard
2026-10-16 04:23:01 +07:00
BFlat bf10fd64f6 Merge pull request #2065 from dotCooCoo/feat/auto-size-type-fewer-layouts
Type: Auto Size area type in columns usually lays the columns out twice an edit
2026-10-16 04:22:45 +07:00
BFlat 2346a4acfb Merge pull request #2067 from dotCooCoo/feat/alt-drag-copies-once-more-tools
Part of #1429: Alt-drags of objects and plane widgets in perspective copy the objects once per drag
2026-10-16 04:22:29 +07:00
BFlat 5b9a9b6d6c Merge pull request #2068 from dotCooCoo/fix/active-artboard-outline
Artboards: the canvas outlines the active artboard in black
2026-10-16 04:22:13 +07:00
BFlat 82d9143502 Merge pull request #2087 from dotCooCoo/feat/layers-panel-filter
Layers: the panel filters its rows by object kind, lock and visibility
2026-10-16 04:21:55 +07:00
BFlat cebc4ed93e Merge pull request #2093 from dotCooCoo/feat/presentation-mode-artboard-keys
View: in Presentation Mode, the left and right arrow keys and a click go between artboards
2026-10-16 04:21:32 +07:00
BFlat 29bf7b7e06 Merge pull request #2099 from dotCooCoo/feat/artboard-navigator-list
Status bar: the artboard navigator lists the artboards by number and name
2026-10-16 04:21:15 +07:00
BFlat e965c0daa2 Merge pull request #2102 from dotCooCoo/feat/properties-artboard-name-options
Properties: Edit Artboards renames the active artboard and opens Artboard Options
2026-10-16 04:21:00 +07:00
BFlat aaeb658292 Merge pull request #2106 from dotCooCoo/feat/rotate-view-menu
View: Rotate View turns the view to a preset angle, and `view.rotate` sets any angle
2026-10-16 04:20:42 +07:00
BFlat d541eb9476 Merge pull request #2116 from dotCooCoo/feat/export-screens-selected-artboards
Export for Screens: the dialog opens with the Artboard tool's selected artboards checked
2026-10-16 04:20:26 +07:00
BFlat cf0be249ba Merge pull request #2134 from dotCooCoo/feat/free-distort-dialog-corners
Effects: Free Distort's corners are dragged in its dialog
2026-10-16 04:20:09 +07:00
BFlat 0594f04e30 Merge pull request #2136 from dotCooCoo/feat/color-panel-options-command
Color panel: Hide Options comes back after a restart, and `ui.colorPanel` sets the mode and options
2026-10-16 04:19:47 +07:00
BFlat b15c28b7c0 Merge pull request #2138 from dotCooCoo/fix/glowing-edges-raster-stylize
Effect menu: Glowing Edges moves to a Stylize submenu under Raster Effects
2026-10-16 04:19:23 +07:00
Murat Vural 1ab3940b3d feat: move and copy ruler guides and art together when selected 2026-10-16 04:19:06 +07:00
Lucas LerouxandClaude Opus 5.5 2abc1d61f7 xtask ci: test vectorcraft-text without its bundled fonts
`cargo test --workspace` builds vectorcraft-text with `bundled-fonts`
(the engine turns it on), so the configuration embedders use without it
was tested only by hand. A `no-bundled-fonts` step after `test` runs its
integration test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:19:05 +07:00
Lucas LerouxandClaude Opus 5.5 1e8f5025c6 Render, SVG: vello_cpu and usvg without their text features: one skrifa, read-fonts and harfrust fewer in the engine
The default `text` features of vello_cpu (glyph runs, through glifo) and
usvg (fontdb, harfrust 0.12, skrifa 0.44 and its read-fonts 0.41) build
another copy of the font stack into the engine, next to the workspace's
skrifa 0.47 and harfrust 0.13. Neither is used: the renderer draws the
glyph outlines vectorcraft-text gives it, and SVG import reads <text>
from the XML itself and never gives usvg a font (usvg leaves out text it
has no font for).

- vello_cpu: its default features but `text` (std, png, u8_pipeline),
  plus f32_pipeline as before.
- usvg: `svgz` only, for gzipped SVG images inside an SVG; `text`,
  `system-fonts`, `memmap-fonts` and `writer` are off. text_layout.rs uses
  usvg as a reference viewer with a font, so crates/svg turns `text` on as
  a dev-dependency feature, as crates/pdf does for krilla's `simple-text`.
- A test pins `svgz`: SVGZ images, linked or as a data URL, import as
  vector art.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:19:05 +07:00
IanHollow 83f40f135a Text: make every supplied font family available to documents
Assisted-by: GPT-6
Signed-off-by: IanHollow <[email protected]>
2026-10-16 04:19:05 +07:00
YukiHiko 8b5e9e4927 G6: expose vertical writing in MCP add_text 2026-10-16 04:19:05 +07:00
YukiHiko 3a7d422046 G4: keep fullwidth punctuation upright without vertical alternates 2026-10-16 04:19:05 +07:00
ham9zah ad3531d464 Curvature: each three points follow the circle through them, curved ends (#1236, in part)
The Curvature tool built its curve as a uniform Catmull-Rom: both handles of a
smooth point a sixth of the chord between its neighbours, the ends of an open
path without handles. Uneven spacing overshot or looped, a J or U from three
points bent sharply at the middle with straight legs, and the end segments
looked flat.

- `curvature_spline` (draw2.rs) makes each stretch of three points follow the
  circle through them: a smooth point's tangent is that circle's
  (`smooth_handles`), each handle `chord / (3 cos²(α/2))` long, α between
  handle and chord capped at 90° (`arc_handle`). The ends of an open path
  mirror their neighbour's tangent in their chord (`end_handle`). A turn
  tighter than 30° (`TIGHT_TURN`) shortens its handles, so a narrow V comes to
  a point. Degenerate points become corners. `path.curvature` and the rubber
  band use it.
- `catmull_rom` is unchanged for the Pencil and Paintbrush fitting.
- `bend_last` (from: smooth), `curvature_move` on a smooth point, the point
  `curvature_insert` adds on a straight segment, and `path.convertAnchor` to
  smooth without x/y (`curvature_smooth`) use the same rule.
- `curvature_extend` gives the new end its end handle unless Alt-clicked
  (`corner` param on `path.curvatureEdit`).
- Neighbours of a point dragged on an existing path keep their tangents (a
  Pen-drawn shape isn't reshaped); the path being drawn re-curves throughout.
2026-10-16 04:19:05 +07:00
el-j 4913418d1c Type tool: Escape returns to the previous selection tool and preserves isolation mode
When entering text edit mode via double-click from a selection tool
(Selection or Direct Selection), pressing Escape now returns to the specific
tool used before double-clicking (rather than always falling back to
Selection).

Additionally, when editing text while in isolation mode (such as when drilling
into nested groups with the Selection tool), pressing Escape exits text editing
while keeping isolation mode active; a second Escape press is required to exit
isolation mode.

- Record the previous tool in TypeTool on double-click from canvas.
- Return to prev_tool on ToolKey::Escape, consuming the key in shortcuts when claimed.
- Add unit test in vectorcraft-tools verifying Escape returns to prev_tool.
- Add integration tests in vectorcraft-ui-egui verifying Escape returns to Direct
  Selection and that isolation mode persists until a second Escape.
- Update docs/target-app-parity.md and ROADMAP.md.
2026-10-16 04:19:05 +07:00
bflatastic 9e1590769e i18n(bg): Hide Template and Show Template 2026-10-16 04:19:05 +07:00
Lucas LerouxandClaude Opus 5.5 55fadb6afe Text: a bundled-fonts feature (on by default) lets embedders that bring their own fonts leave ours out
The nine OFL faces in `assets/fonts` are compiled into every build of
vectorcraft-text: 3.4 MB of the binary, about 1.6 MB of a gzipped wasm.
An embedder that loads every font at run time with `FontDb::add_font`
(a web app serving its own font catalogue, say) ships them twice and
can't turn them off.

- vectorcraft-text: `bundled-fonts`, a default feature, holds the
  `include_bytes!` list. Off, a database starts without them (craft-fonts
  and system fonts are unaffected) and text has no glyphs until a font
  is added, as when the bundled fonts fail to parse.
- vectorcraft-engine: a `bundled-fonts` default feature forwards to it,
  so the apps and any embedder using the engine keep the fonts unless
  they build it with `default-features = false`.
- The workspace declares vectorcraft-text without default features, so
  no library crate turns the fonts back on. The tests of cad, metafile
  and tools, which lay type out, ask for the feature in their
  dev-dependencies; the other crates' tests get it through testkit.

`cargo test --workspace` builds with the fonts, as before. A new
integration test, `without_bundled_fonts`, covers the configuration
without them (`cargo test -p vectorcraft-text --no-default-features
--test without_bundled_fonts`): the database starts without them, text
in no font lays out with no glyphs, and a font added later is used and
is the fallback for families the database lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:19:05 +07:00
el-j 28daaf473c Canvas: double-clicking type with the direct selection tool puts the caret there
With the Direct Selection tool ("white arrow", shortcut 'a'), double-clicking
a text element (either standalone or nested inside a group) now switches to
the Type tool and immediately positions the caret at the clicked character,
matching the behavior previously introduced for the Selection tool in #284.

- In DirectSelectionTool::pointer, detect double-click hits on NodeKind::Text
  when !self.group and emit Action::SwitchTool("type").
- Group Selection (group: true) retains its existing group cycling behavior.
- Add unit test in vectorcraft-tools verifying tool switch on both standalone
  and grouped text.
- Add headless egui integration test in vectorcraft-ui-egui verifying tool switch,
  caret positioning, and inline editing without triggering shortcuts.
- Update docs/target-app-parity.md and ROADMAP.md.
2026-10-16 04:19:05 +07:00
BFlat fc51d66659 Merge pull request #2140 from dotCooCoo/feat/save-template-ait
Templates: Save as Template can also write a PDF-compatible .ait
2026-10-16 04:19:04 +07:00
wstockandClaude Fable 5.1 7b8730ec14 View › Hide Template (Cmd+Shift+W): template layers leave the canvas and come back (#2000)
Illustrator's View menu has Hide/Show Template (Shift+Ctrl+W) to leave
template layers off the screen while tracing. VectorCraft drew them (dimmed)
whatever the view, with no way to hide them short of disabling the template
option on each layer and losing the dimming.

- `view.template` toggles `ViewFlags::templates` (default on, kept across
  settings files); the View menu lists Hide Template after Lock Slices, and
  the label flips to Show Template.
- The canvas renders with the new `RenderOptions::hide_templates`, which
  leaves template layers out and nothing else; `skip_templates` (exports and
  thumbnails) also drops guide paths, so it is not reused.
- The fifteen menu catalogs translate both labels; `DYNAMIC_LABELS` lists them.

Tests: render `hide_template_leaves_only_the_template_layers_out` (the
template layer's square and image go, a guide path on another layer stays);
canvas `hide_template_leaves_template_layers_off_the_canvas` (real egui
frames: painted pixels, none after Hide Template, the same after Show
Template, each re-rendering once); the dynamic-label and catalog tests cover
the new pair.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-16 04:08:44 +07:00
NetterSilverandClaude Haiku 5.5 c54a9b0bef Reveal the active layer in the Layers panel after a canvas selection
Fixes #2049

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:42 +07:00
Ayberk A. a86ff7f177 Document Info: Objects gives the paths' total length (#1958)
Document Info › Objects counts the paths but didn't say how long they are,
which sign and print work reads there (an LED strip along the letters, a
cut line). `document.info` now adds up the length of every path it counts,
the pieces of compound paths included and guides left out, and returns it
as `pathLength` (pt); the Objects section shows it as Path Length in the
document's units, right after the Paths count, so Selection Only gives the
selected paths' length. The label is translated in the catalogs that
cover the menus; pt-br only takes strings the UI itself shows.
2026-10-16 04:08:12 +07:00
Fabian GrajalesandClaude Opus 5.5 7135a377f8 Panel menus light up under the pointer, like the menu bar's
Panel (≡) menu rows, and the Control bar's image menu, had no frame at all,
so nothing showed which row the pointer was on. They now draw the hover
frame the menu bar's items draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:10 +07:00
Fabian GrajalesandClaude Opus 5.5 91e1434cbf Swatches panel follows the selection's swatch
Selecting art scrolls the tiles to its swatch once (centred) and clears the
panel's own selection, so the art's swatch is the one highlighted; so does a
change of the selection's paint (after Recolor Artwork). An unlinked process
colour highlights the swatch of that very colour. A selected image, or text
in a colour no swatch has, highlights nothing (not None).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:08 +07:00
Fabian GrajalesandClaude Opus 5.5 5a43337a42 Replace a selected image from the Control bar, with its own aspect ratio
The Control bar's file name is a menu: Replace…, Go To Link, Edit Original,
Update Link and Placement Options…. Replace… (also in the Properties panel,
in place of Relink…) puts the picked file in the image's place fitted inside
the old bounds and centred, keeping the new file's aspect ratio instead of
stretching it into the old one. links.relink takes `preserve` (transforms,
bounds, fileDimensions, fit or fill) to override the Placement Options for
that read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:06 +07:00
Fabian GrajalesandClaude Opus 5.5 5252c3d86a Recolor Artwork: library colours apply as their swatches; import a library
With Limit to Library, a row's new colour is the library swatch itself: art
links to a global or spot colour (a Pantone-style spot colour stays a spot
colour in its own model, tints keep their tint) and a process swatch gives
its colour in its own model, instead of being converted to the model of the
colour it replaces. The swatches used join the document's swatches (one the
document already has, same name and colour, is reused); recolor.apply
reports them as `swatches`. A folder button next to Limit to Library loads a
library file (.ase, .acb, .gpl, .vcswatches or a document's swatches) and
limits to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:04 +07:00
Fabian GrajalesandClaude Opus 5.5 1ba448f7f3 Recolor Artwork from the Contextual Task Bar and the Control bar
A palette button opens Recolor Artwork when the selection has colours to
recolour (solid colours, gradient stops or mesh points, inside groups too):
in the Contextual Task Bar, before Lock, and in the Control bar, after the
Graphic Style picker. Art painted with None shows no button.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 04:08:01 +07:00
turtwig 032b7b7be1 Glyphs panel: zoom buttons resize the preview and the step is kept
Zoom Out and Zoom In sit to the right of the font family and style menus. They step the preview cells through 16, 22, 30, 42, 56, 72 and 96 pt; the grid still opens at 30 pt, and the buttons stop at the ends. The step is stored on the UI state and written with the other panel choices in ui.json when the app quits. Zero is the default and is left out of the file, so older preferences stay as they are.
2026-10-16 04:07:59 +07:00
andeecollardandClaude Opus 5.5 8fffa4f6f9 Image Trace: regression tests for shared closed boundaries
Abutting traces must draw each edge between two neighbouring regions once:
the filled paths on either side follow one curve, one of them reversed, and
together cover the image without gaps or overlaps. These tests check that
from the trace output alone, with no renderer: points along each path's
outline must lie within 0.001 px of another path's outline, the paths' areas
must sum to the image's, and every point of a 4 x 4 per-pixel grid must lie
in exactly one path.

The images are drawn in code, hard-edged and anti-aliased, and traced in
Color (Abutting) and Black and White, each at the default settings, Paths and
Corners 0 and 100, Snap Curves To Lines, and Shapes. They cover a closed island
in a background, a ring with and without a core of a third colour, a wavy
two-colour seam, a three-region junction, outlines that leave a junction and
return to it (the 111 / 101 / 110 pattern at 1, 2, 3 and 6 px, and framed),
squares touching at a corner, a one-pixel hole and a one-pixel island (kept at
Noise 1, dropped together with what they leave at Noise 25), separate areas of
one colour, and slanted hatches with one-pixel paper channels. A further test
shows the check finds a 0.01 px shift and stacked (Overlapping) layers.

Two tests are ignored as documented current gaps:
- At Paths 0 and Corners 0, a piece returning to its own junction collapses:
  its region is dropped and its area left uncovered (draft #2205 fixes it).
- With Shapes, a region whose outline runs through a junction becomes an exact
  shape its neighbour does not share (up to 0.0039 px in Black and White).

No tracer code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:57 +07:00
andeecollardandClaude Opus 5.5 dbcd32d21a Image Trace: grayscale gradients use grey stops
In Grayscale mode with Gradients on, the trace could fill paths with
coloured linear gradients (for example a blue-to-orange ramp) beside
grey flat fills. Gradient detection ran on the RGB image, so the stops
and the gradient's palette colour (its middle) were the source colours,
while Grayscale's flat fills come from each pixel's luma.

Grayscale now finds gradients, and settles their edges, in a copy of the
image reduced to the same Rec. 601 luma the flat grays are quantised
from. Every channel of that copy is equal, so the fitted stops, the ends
carried to the area's edges and the palette entry are all grey. Color
mode is unchanged: it still detects on the RGB image (trace output for
gradient fixtures is byte-identical before and after).

Adds grayscale_gradients_are_grey: a code-drawn sky ramp over flat
shapes with a ramp rectangle, traced in Grayscale with 4 grays and
Gradients on, must still produce a gradient, and every path colour,
gradient stop and palette entry must be grey.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:55 +07:00
andeecollardandClaude Opus 5.5 5d8b5ddc6c Image Trace: keep shared corners identical at nearby junctions
When a long horizontal or vertical run crosses a three-colour junction one
or two pixels before a corner, the corner correction for that run borrowed
a neighbouring crack's shift that the region on the other side of the
junction never applied, leaving hairline gaps between abutting colours.
The correction now applies only when all three cracks it uses lie in the
same shared piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:53 +07:00
andeecollardandClaude Opus 5.5 75b91e9ef3 Docs: leave the status lines to main
The 'Last updated · Change' status lines in docs/roadmap.md, docs/target-app-parity.md and docs/image-trace-quality.md change with almost every merge to main, so this PR's edits to them conflicted each time main moved. They are reverted to main's text; this PR's evidence bullets, history rows and table entries stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:51 +07:00
andeecollard 4714567240 M12: retain the interior of collapsed closed trace outlines 2026-10-16 04:07:50 +07:00
andeecollardandClaude Opus 5.5 477ff16357 Docs: leave the status lines to main
The 'Last updated · Change' status lines in docs/roadmap.md, docs/target-app-parity.md and docs/image-trace-quality.md change with almost every merge to main, so this PR's edits to them conflicted each time main moved. They are reverted to main's text; this PR's evidence bullets, history rows and table entries stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-16 04:07:45 +07:00
andeecollard c7aac7c250 M12: preserve tiny traced components and holes 2026-10-16 04:07:45 +07:00
bflatastic fd8cd62d17 i18n(bg): rows for the Layers panel filters and Color Panel Options 2026-10-16 04:04:51 +07:00
dotCooCoo 04ed30a23d Effects: Scribble has a Variation for Path Overlap, Curviness and Spacing, and stores 270° as -90°
The reference app's Scribble Options have a Variation for each of Path
Overlap, Curviness and Spacing. Scribble had one `variation`, which
moved each line's ends both along the line and across it, and every
turn of a scribble took the same curviness. An angle such as 270 was
stored as given.

Scribble now takes `overlapVariation` (pt, default 0.5), which moves
the ends along the line, `spacingVariation` (pt, 0.5), which moves
them across it, and `curvinessVariation` (%, 0), which varies the
curviness from one turn to the next. The dialog shows each Variation
after the option it varies, and the Appearance panel lists all three.
A Scribble saved or scripted with the earlier `variation` reads it as
the Path Overlap and Spacing variations. With the defaults, and with
the earlier `variation`, Scribble draws the same outline as before.
`effect.apply` and `effect.setParams` store an angle above -180 and up
to 180, so 270 is stored as -90, and the dialog shows the stored value.

The Appearance panel's inline editor lists an effect's values in
their current form, as the dialog does. It listed an earlier key next
to the defaults of the keys that replace it.

Docs: docs/effects-parity.md (Stylize row), docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 6f9ee7825b Effects: Smart Blur's Mode menu has Edge Only and Overlay Edge
Effect › Blur › Smart Blur had only its Normal mode: the dialog and
blur.smart took Radius, Threshold and Quality and drew the blur.

Smart Blur now takes a `blurMode` param ("normal", "edgeOnly" or
"overlayEdge"; "normal" by default), which its dialog shows as a Mode
menu. Edge Only draws the edges in white on black, and Overlay Edge
draws them in white over the Normal blur. A pixel is on an edge when
one of its four nearest pixels differs from it by more than the
threshold in a channel and its channels, alpha included, add up to at
least that pixel's. The white and the black keep each pixel's
coverage. Normal draws the same pixels as before, and an effect saved
without `blurMode` draws as Normal.

Docs: docs/effects-parity.md, docs/gaps.md, docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 580aa8daa5 Clipboard: Copy and Cut make the system clipboard's formats on the background thread
Copy and Cut made the system clipboard's formats in the frame that ran
them. The formats include the SVG markup and a PNG of the selection at
1 px per pt, up to 4,096 px a side. In a release build on Windows, Copy
of 50,002 rectangles on a 1600 x 1200 pt page took 1.00 s in its frame.

The thread that writes background saves and exports now makes the
formats from a copy of the clipboard, and the app writes them in a
later frame. The same Copy takes 0.18 s in its frame, and the SVG and
PNG are the same bytes. While that thread has a save, an export, a print or
a recovery copy to finish, Copy and Cut make and write the formats at
once. A later Copy or Cut, or text copied in a field or with the Type
tool, drops formats that are not written yet. Until they are written,
Paste on the canvas takes the internal clipboard, and while a text
field or the Type tool has the keyboard the app waits for them.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo f14fbded0e Artboards: the options icon and the panel menu open Artboard Options
In the Artboards panel, a click or a double-click on a row's options
icon (the page at the right end of the row) chose the Artboard tool on
that row's artboard and opened no dialog. The panel menu's Artboard
Options… also only chose the Artboard tool.

A click on the active artboard's icon, or a double-click on another
row's icon, now opens Artboard Options for that row's artboard. The
first click on another row's icon makes its artboard active, as a
click on the row does. The panel menu's Artboard Options… opens the
dialog for the active artboard. Both run tool.options with tool
"artboard", as a double-click on the Artboard tool's button does, and
they no longer choose the Artboard tool. The icon's tooltip is now
Artboard Options, and the old tooltip's row is removed from the 15
catalogs.

Opened with another tool, Artboard Options did not move the art with a
new X or Y. It now moves the art when the Artboard tool's Move Artwork
with Artboard option is on, as it does while that tool is in use.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 446ccb1fd0 3D and Materials: twisted walls and bevels are drawn back to front
At 180° and 360° of twist, Extrude & Bevel and Inflate drew some wall
strips in the wrong order, and a far strip showed through a near one.
Each strip ran the whole length of an outline edge and was sorted by
the depth of its center, while a twisted wall can cross it far from
that center. The caps were sorted the same way.

Twisted walls and bevels are now drawn in pieces about as long as a
wall slice is wide (fewer near the 32,768-facet limit), and twisted
bevel steps turn at most 6° each, as wall slices do. A cap, or
Inflate's flat back, that faces the viewer is drawn after the walls,
and edge-on pieces of a twisted wall keep their own depth. Pieces that
face the viewer are drawn where their strip as a whole faces away, so
a twisted solid seen from behind shows its walls' corners around the
back cap. Untwisted solids give the same facets as on main.

Over 80 twisted squares, the facet seen over a point lay up to 79.7 pt
behind a nearer facet on main and now lies at most 5.0 pt behind.
"Twisted" in 72 pt type, extruded with a 360° twist, takes 12,311
facets instead of 7,622 and 3.1 ms instead of 1.9 ms.

Docs: docs/three-d.md.
2026-10-16 04:04:51 +07:00
dotCooCoo 4440463315 Effects: Effect › Pathfinder uses the glyph outlines of type in the group
Effect › Pathfinder left type in the group out of all ten operations,
on the canvas, in Expand Appearance and in exports. The text outliner
returns type as a group with one path per run. `leaves` in
crates/effects/src/group.rs took that group as one leaf, and
`geometry` gives no path for a group. A 100 x 100 pt square grouped
with a 100 pt "H" inside it kept all of its 10,000 pt² after Subtract
and Expand Appearance. In the shape modes a member group with type in
front also lost its paint, because it took the style of the outline
group, which has none.

`leaves` now takes the paths and compound paths of the outline art at
every depth, as it does for a member group, and gives each one the
type's opacity and blend mode. The same square keeps 8,684.88 pt² after
Subtract and Expand Appearance, its 10,000 pt² less the glyph's
1,315.12 pt².

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 6928f422d3 CLI: perf --deep reports the peak working set on Windows and the peak resident memory on Linux
perf --deep printed the memory at eight points of its run, and no row
showed memory that the run took and freed between two readings. With
--paths 2000, a release build on Windows printed 430 MB as its largest
row, and the process's peak working set was 612 MB. On Windows the
memory row after rendering the photos also printed its value 3 columns
right of the others: its name is 55 characters, and the name column is
52 characters wide. The tracked row for the photos printed its value 2
columns right of the other tracked rows: its name is 54 characters, and
their name column is 52 characters wide. In the budget table the handle
and segment drag rows printed their values 1 and 2 columns right of the
other rows: their names are 41 and 42 characters, and the name column
is 40 characters wide.

Each memory row now also gives the peak so far: the peak working set
(PeakWorkingSetSize, from the same GetProcessMemoryInfo call) on
Windows, and VmHWM from /proc/self/status on Linux. On macOS the rows
give no peak. A new last row gives the memory after the SVG export and
the save → open → save at the end of the run. The name columns of the
memory rows, the tracked rows and the budget table are 56, 54 and 42
characters wide. The help says on which systems the rows give the peak.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo c6837ca3ae Render: objects with plug-in effects follow the plug-ins installed
The renderer kept the shapes it drew for plug-in effects while the
object stayed the same. When a plug-in was installed for the first
time, installed again or removed, a renderer that had drawn the object
went on drawing the shapes from before the change.

When the plug-in registry's revision changes, the renderer now drops
the effect geometry and the effect art of objects with a plug-in
effect, and the cull bounds of every object that is or holds a symbol
instance or an object with a plug-in effect. Each thread then drops the
pattern tiles whose art has a plug-in effect or shows symbols or
patterns. The renderer adds the revision to the inputs of a raster
when its content has a plug-in effect, or shows symbols or patterns
while the document's symbol or pattern art has one, and to the key of
a live object's evaluation when the object, or an object it shows, has
a plug-in effect.

With other builds running, a temporary release test timed that key for
the 400 live objects in each of three documents at 11 to 609
microseconds a frame, against 9 to 433 before.

Docs: docs/target-app-parity.md, docs/gaps.md.
2026-10-16 04:04:50 +07:00
dotCooCoo b34b9dadcc Artboards: Fit to Artwork Bounds and Fit to Selected Art resize the active artboard
Object › Artboards › Fit to Artwork Bounds and Fit to Selected Art ran
artboard.fitToArt and artboard.fitToSelection without an index, so
they resized artboard 1 whichever artboard was active. The Artboards
panel menu's Fit to Artwork Bounds already passed the active artboard.

The app now passes its active artboard as the index of the two
commands when a call names none, as it passes the artboard to Align to
Artboard, All on Active Artboard and Paste in Place, in Front and in
Back. That covers the menu items, keyboard shortcuts, the command
palette and MCP's run_command in remote mode. A batch passes its
artboard as the index to their steps that name none, and a recorded
action leaves the index out, so an action resizes the artboard that is
active when it plays. The params texts of the two commands and of
command.batch describe where the index comes from.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 867e72b085 Type: Auto Size area type in columns usually lays the columns out twice an edit
For Auto Size area type in columns, every text edit, each keystroke of
the Type tool included, laid the text out in one column and then laid
the columns out at that height and at each step of a bisection for the
least height that holds the text. In the release-build timing test
auto_height_in_columns_typing_timing (stories of 5,000 to 50,000
characters at 9 pt in a frame 500 pt wide), a keystroke laid the
columns out 18 to 22 times, and typing at the start of the story of
50,000 characters in 2 columns took 1,670 ms a keystroke.

The bisection now runs on the lines of the one-column layout.
columns_hold computes where those lines would sit in the columns, with
each column's first line at its first baseline below the column's top
(LineInfo::first_baseline, new in vectorcraft-text). The columns are
then laid out at the upper height the bisection ends on and, when they
hold the text there, at the lower one, unless that is the 1 pt it starts
from. When they hold the text at the upper height and not at the lower
one, Auto Size takes the upper height. When taller columns always hold
the text, the old search ends between the same two heights. When the
text wraps around other objects or does not fit the one-column layout,
or when that check fails, the columns are laid out at every step as
before. The same keystroke now takes 292 ms.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 0ffae0c3fa Part of #1429: Alt-drags of objects and plane widgets in perspective copy the objects once per drag
On each pointer move of an Alt-drag with the Perspective Selection
tool, Session::preview put the drag's snapshot back and ran
perspective.move with copy again, and persp_edit copied every selected
object again. An Alt-drag of a plane widget with the Perspective Grid
or Perspective Selection tool ran perspective.plane.move with objects
"copy" again, and plane_move copied every object on the plane again.

Both commands now take their copies from preview_copies, as
apply_transform does for the Selection and transform tools: the first
preview copies the objects into a clone of the snapshot, and each later
preview of the same objects on the same snapshot maps only those
copies. The copies keep their ids, and the document, the selection,
the undo step and the journal are the same as before. When the stored
grid still lists attachments of its own, as grids saved before
attachments were kept on the objects do, both commands copy on every
preview as before. With 25,000 rectangles attached to the left plane,
a release build of vectorcraft-cli took a median of 424.8 ms for each
Alt-drag move of the objects after the first preview and takes
328.9 ms with this change. An Alt-drag of the plane's widget took
340.3 ms a move and takes 256.9 ms.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 9e832d32da Artboards: the canvas outlines the active artboard in black
The canvas drew artboard 1's edge in black, 1 px wide, whichever
artboard was active, and drew every other artboard's edge in gray,
0.6 px wide.

It now draws the active artboard's edge in black and draws the other
artboards' edges in gray, at the same widths. The active artboard is
the artboard that the status bar's navigator shows and that Align to
Artboard and Paste in Place use. With several artboards selected by
the Artboard tool, it is the one the tool draws with handles and its
name.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 29eb490381 Layers: the panel filters its rows by object kind, lock and visibility
The Layers panel narrowed its rows by name (Search All) or to layers
alone (Show Layers Only).

The panel now has a Filter button at the right of Search All. Its
popup holds Clear All and a check box for each object kind under By
Object (Paths, Shapes, Text, Images, Effects, Groups, Linked Files,
Guides) and for each row state under By Layer (Locked, Hidden). A row
stays when it is of a ticked kind, is in a ticked state and has the
search text in its name, and a section with nothing ticked keeps every
row. The rows that hold a kept row stay too, as they do for the
search. The button is lit while a filter is on. The filter lasts the
session and does not change the document, its open rows or the
selection. ui.layersFilter {objects?, layers?} sets it. The button
shows a new icon, assets/icons/dc-filter.svg, with its row in
ASSETS.md.

The panel lists its rows again when the filter changes, as it does
when the search changes. On a layer of 5,000 groups of 10 paths, a
release build lists the rows for a filter in 2.9 to 3.6 ms.

Docs: docs/mcp.md (Layers panel), docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 697f238b60 View: in Presentation Mode, the left and right arrow keys and a click go between artboards
In Presentation Mode the arrow keys moved the selection, and the
active tool received a click and selected or drew as it does in
Normal Screen Mode. No key or click moved the view to another
artboard.

Left Arrow and Right Arrow now go to the previous and the next
artboard, and a click anywhere goes to the next one, through
view.goToArtboard, as the status bar navigator's Previous and Next
buttons do. On the first artboard Left Arrow leaves the view as it
is, and on the last artboard Right Arrow and a click leave it as it
is. No arrow key moves the selection in the mode, and the tool gets
no press, drag or hover, so a click selects and draws nothing. A drag
with the middle button or with Space held no longer pans the view
there; the mouse wheel still zooms and scrolls it.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 8ca25c955e Status bar: the artboard navigator lists the artboards by number and name
The status bar's artboard navigator showed only the active artboard's
number between its first, previous, next and last buttons, and it had
no list of the artboards.

The number is now a drop-down field that shows the active artboard's
number and name. Its list shows every artboard by number and name, as
the Artboards panel lists them. Choosing one runs view.goToArtboard,
the command the buttons run, which makes that artboard the active one
(and the Artboard tool's, while that tool is in use) and fits it in
the window. The list lays out only the rows in view and at most one
more, and opens with the active artboard's row in view. In a test
build with 1,000 artboards, the two frames that open the list take
0.12 ms and 0.04 ms, and a frame with the list open takes 0.03 ms. The
timed frames are headless and draw only the status bar.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 5a5f1a0995 Properties: Edit Artboards renames the active artboard and opens Artboard Options
With the Artboard tool, the Properties panel showed the active
artboard's name as a label, and no control in the panel opened
Artboard Options.

The name is now a text field. Typing a new name and pressing Enter or
clicking elsewhere renames the active artboard in one undo step
through artboard.setProps, as the Artboards panel's rename does;
pressing Escape or leaving the field empty keeps the old name. A click
on the Artboard Options icon beside the field runs tool.options with
tool "artboard", which opens Artboard Options for the active artboard,
as a double-click on the Artboard tool's button does.

Docs: docs/target-app-parity.md.
2026-10-16 04:04:50 +07:00
dotCooCoo 799155cdd0 View: Rotate View turns the view to a preset angle, and view.rotate sets any angle
In the View menu, Reset Rotate View was the only rotation item. The
status bar's angle menu set its twelve preset angles by writing the
view's rotation itself, and no command took an angle to turn the view
to.

View › Rotate View now lists the status bar's angles (0°, 15°, 30°,
45°, 60°, 90°, 180°, -15°, -30°, -45°, -60° and -90°) and checks the
one the view is turned to. The new UI command `view.rotate {angle}`
turns the view to any angle about the center of the canvas and
returns the angle it set, normalized to (-180, 180] as a Rotate View
drag normalizes it. The menu's items and the status bar's angle menu
run it from one list of angles; the status bar sets the same angles as
before. Without a document the command returns "no document" and the
items are disabled. Run from the command palette, which passes no
angle, Rotate View opens a dialog that asks for the angle, with the
view's current angle filled in.

The de, it, ja, ko, pl, pt-br, ru, uk, vi and zh-hant catalogs have a
new "Rotate View" row. The strings the catalogs cover leave out the
angle labels, as they leave out Type › Size's point sizes.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo 1f6ef9dcf3 Export for Screens: the dialog opens with the Artboard tool's selected artboards checked
Export for Screens opened on the artboard choice the document last
exported with (all artboards by default), whichever artboards were
selected with the Artboard tool or in the Artboards panel.

While the Artboard tool is in use, File › Export › Export for Screens…
now opens with the artboards the tool has selected checked, and Export
Artboard writes only those. They are the artboards the Artboards panel
highlights, and they are checked even after a Full Document export.
With another tool in use, the dialog still opens on the saved artboard
choice. The other fields show the saved settings in both cases, and the
params text of file.exportForScreens describes the rule.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo fc0e926cda Effects: Free Distort's corners are dragged in its dialog
Free Distort's dialog showed the four corners as read-only text, and
so did the Appearance panel's inline editor. Only effect.apply and
effect.setParams could set them.

The dialog now shows a box in the proportions of the first selected
object's bounds with a handle on each corner. Dragging a handle moves
that corner, and the preview follows. A handle focused by a click or
Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset
puts the four corners back on the box. Each handle has a
screen-reader name. On the corners row of the Appearance panel's
inline editor, Edit… opens the dialog.

Docs: docs/effects-parity.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo 940f425a01 Color panel: Hide Options comes back after a restart, and ui.colorPanel sets the mode and options
Show Options and Hide Options were kept in egui's temporary memory, so
the Color panel showed its options again after every restart. No
command set them or the panel's mode.

- `UiState::color_hide_options` holds Hide Options and is saved with the
  preferences. Its reader reads `true` as hidden and any other value as
  shown, so a bad value leaves the other preferences as they are.
- `ui.colorPanel {mode?, options?}` picks a mode as the panel menu does
  (the active color converts to the mode's model as one undo step) and
  shows or hides the options. It answers `{mode, options, shown}`. A mode
  the menu does not list, `lab` included, answers an error. The panel
  menu's Show Options and Hide Options call the command's function.
- `ui.inspect` reports the setting as `ui.color_hide_options`.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
dotCooCoo ea59b066d4 Effect menu: Glowing Edges moves to a Stylize submenu under Raster Effects
On main, Glowing Edges… is listed in the Effect menu's vector Stylize
submenu, after Drop Shadow, Inner Glow, Outer Glow and Feather. The
reference app lists it in the Stylize submenu of its Photoshop effects,
below the vector effects.

The Effect menu now has a Stylize submenu under Raster Effects, between
Sharpen and Texture, and Glowing Edges is listed there. The menu sends
the effects at the Stylize path that PIXEL_EFFECTS names to that
submenu, and the vector Stylize submenu keeps its six effects. The
effect's id, its catalog entry, its effect.list entry and Apply Last
Effect do not change.
2026-10-16 04:04:49 +07:00
dotCooCoo 427cd65f30 Templates: Save as Template can also write a PDF-compatible .ait
File › Save as Template always wrote a .vctemplate. The save panel
offered only that type, file.saveAsTemplate ignored its format param,
and a name typed with .ait got .vctemplate added. New from Template
already opened a .ait as an untitled document.

Save as Template now also writes a .ait: the PDF-compatible file Save
As writes for .ai, carrying the document. The desktop save panel lists
VectorCraft Template first and the .ait type beside it, and the web
build's Save as Template dialog gets a Format menu with the same two
types. file.saveAsTemplate writes .ait for format: "ait", or for a path
ending in .ait when no format is given, and takes the .ai options;
file.formatOptions answers for ait. .vctemplate stays the default.
Exports and Export As still do not write .ait.

Docs: docs/mcp.md, docs/file-format-parity.md (.ait row),
docs/target-app-parity.md.
2026-10-16 04:04:49 +07:00
BFlat ed96a12196 Merge pull request #2201 from dotCooCoo/fix/paste-copied-svgz
Clipboard: a .svgz file copied in a file manager pastes as its art
2026-10-16 04:02:01 +07:00
BFlat be97caf90b Merge pull request #2148 from dotCooCoo/fix/paste-ai-file
Clipboard: a .ai or .ait file copied in a file manager pastes as its art
2026-10-16 04:01:44 +07:00
BFlat c48aba77e3 Merge pull request #2149 from dotCooCoo/fix/postscript-message-and-params
PDF/.ai import: the PostScript file error names File › Open and File › Place, which read the file
2026-10-16 04:01:28 +07:00
BFlat 6a56c7a5f6 Merge pull request #2202 from EricKuck/fix/pdf-shading-curve-stops
PDF import: an axial or radial shading keeps its function's curve when sampled into stops
2026-10-16 04:01:11 +07:00
BFlat 4a6d52e7ea Merge pull request #2218 from dotCooCoo/fix/same-value-spot-inks
PDF/.ai import: spot inks with the same values and different names keep their own swatches
2026-10-16 04:00:54 +07:00
BFlat fda7c9fae3 Merge pull request #2129 from dotCooCoo/fix/pdf-page-user-unit
PDF/.ai import: a .ai whose page has a UserUnit opens with its layers at the page's size
2026-10-16 04:00:36 +07:00
BFlat 0b10aa67a8 Merge pull request #2199 from dotCooCoo/fix/ai-cmyk-images-keep-samples
.ai import: CMYK images in the editing data keep their ink amounts
2026-10-16 04:00:18 +07:00
BFlat 2133a21096 Merge pull request #2051 from EricKuck/fix/ai-mesh-edge-points
AI import: a gradient mesh patch's edges bend through the anchors listed between its corners
2026-10-16 03:59:59 +07:00
BFlat ca8fb9a543 Merge pull request #2058 from EricKuck/fix/ai-pattern-gradient-canvas
AI import: a gradient in a pattern's definition is placed from the canvas, as the file's PDF part draws it
2026-10-16 03:59:49 +07:00
BFlat faba555c63 Merge pull request #2052 from EricKuck/fix/ai-styled-object-keeps-look-transparency
AI import: a styled object read in place of its drawn look keeps the transparency written on the look
2026-10-16 03:59:31 +07:00
BFlat 97d7e7488a Merge pull request #2050 from EricKuck/fix/ai-blurred-art-drawn-look
AI import: a drawn look that is an image stands for its object, as one of paths does
2026-10-16 03:59:15 +07:00
BFlat 029ab098a3 Merge pull request #2046 from EricKuck/fix/ai-clip-path-in-group
AI import: a clipping path alone in a group clips the clipping group around it
2026-10-16 03:58:57 +07:00
BFlat 2422d0ea5b Merge pull request #2030 from midasmatterhorn/fix/issue-2004-ai-opacity-mask
eps: an opacity mask in the editing data is named, not drawn whole (#2004)
2026-10-16 03:58:38 +07:00
BFlat e24edbab41 Merge pull request #2029 from midasmatterhorn/fix/issue-2003-ai-layer-opacity
eps: a layer's own transparency in the editing data is read (#2003)
2026-10-16 03:58:21 +07:00
BFlat 16af375873 Merge pull request #2060 from Silvertree2010/fix/2005-pdf-ai-import-a-document-with-on
Open PDFs and .ai files with an image over 256 MB decoded
2026-10-16 03:58:01 +07:00
dotCooCoo c246fc2f0e Clipboard: a .svgz file copied in a file manager pastes as its art
Paste takes a file copied in a file manager by its flavor, which
file_flavour tells from the file's first 4,096 bytes through
fileio::detect. detect names gzip data SVGZ unless it is a compressed
native file, and file_flavour gave no flavor for the format id svgz.
So a copied .svgz file did not paste as its art, although
clipboard.importSvg, the SVG flavor's command, reads SVGZ data.

A copied file whose gzip data unpacks to SVG markup now pastes through
the SVG flavor. file_flavour unpacks the head it is given with the
gzip decoder the SVGZ reader uses, up to where the head ends, and
checks the first 4,096 unpacked bytes with looks_like_svg, as detect
checks an uncompressed file, reading them as UTF-16 after a UTF-16
byte order mark. Other gzip files get no flavor, as before.

Docs: docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
dotCooCoo c5a1504e99 Clipboard: a .ai or .ait file copied in a file manager pastes as its art
Paste takes a file copied in a file manager by its flavor, which
file_flavour tells from the file's first 4,096 bytes through
fileio::detect. detect names PDF or PostScript bytes in a file called
.ai or .ait by that extension, and file_flavour gave no flavor for
the format ids ai and ait. So a copied .ai or .ait file did not paste
as its art, while the same bytes in a .pdf or .eps file did.

A copied .ai or .ait file whose bytes are a PDF now pastes through the
PDF flavor (clipboard.importPdf), and one whose bytes are PostScript
pastes through the EPS flavor (clipboard.importEps). Other files
with those extensions paste as before. A .ai saved without its PDF
part also gets the PDF flavor, and Paste answers it with
clipboard.importPdf's error, as it does for the same file named .pdf.

Docs: docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
bflatastic b9341d3b3b i18n(bg): the PostScript file error names File › Open and File › Place 2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 f3b4c3ec78 PDF import: an axial or radial shading keeps its function's curve when sampled into stops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
bflatastic 136810bee2 PDF/.ai import: the tint transform test passes no ink names to space_of 2026-10-16 03:36:20 +07:00
bflatastic 90e6ff3de1 PDF/.ai import: the tests added on main pass the page's unit to layered_ai 2026-10-16 03:36:20 +07:00
dotCooCoo dc179c3671 .ai import: CMYK images in the editing data keep their ink amounts
When a .ai file, or an EPS with editing data, opened from its editing
data, the reader converted each embedded 8-bit CMYK image to RGB and
stored it as a PNG, so the document lost the image's ink amounts. When
the same .ai opens from its PDF part, the PDF importer keeps them in a
CMYK TIFF.

The reader now stores those samples with ImageBlob::cmyk_tiff, as the
PDF importer does (crates/pdf/src/import_image.rs). An alpha channel
that hides part of the image becomes the image's opacity mask, a
grayscale image over it, as in PDF import. Grayscale, RGB and 1-bit
images still open as RGBA PNGs.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
bflatastic 8af0459232 AI import: a gradient mesh keeps at most 65,536 points, however its patches are cut 2026-10-16 03:36:20 +07:00
EricKuck e93ba4c83f AI import: a gradient in a pattern's definition is placed from the canvas, as the file's PDF part draws it 2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 9ebd30b8bb AI import: a styled object read in place of its drawn look keeps the transparency written on the look
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
dotCooCoo 31ffbb2152 PDF/.ai import: the PostScript file error names File › Open and File › Place, which read the file
document.pdfInfo and clipboard.importPdf, given a PostScript file (an
EPS, or an .ai in an older PostScript format), failed with "this is a
PostScript file (...), which can't be opened yet: save it as PDF,
PDF-compatible .ai or SVG". document.open and file.place read the same
file through the EPS reader. The error told users to convert a file that
VectorCraft opens.

The error now says the file is not a PDF and that File › Open and
File › Place read it as EPS. The 13 catalogs that carry the message get
the new text.
2026-10-16 03:36:20 +07:00
dotCooCoo ca145c5265 PDF/.ai import: spot inks with the same values and different names keep their own swatches
A PDF can define spot inks that have different names and the same
alternate space and tint transform, such as Blue1 and Blue2, both RGB
0 0 255. The interpreter drops the ink name, so the importer matches an
interpreted color to the file's Separation and DeviceN spaces by the
tint transform. The spaces of two such inks matched the same color, so
their paths imported as plain RGB and neither ink became a spot swatch.
This happened with both inks on one page, with each on its own page,
and in the PDF part of a .ai file.

The page walk in import_scan.rs, which follows the interpreter's order
for layers and overprint, now also records the ink of each path, from
the color space set by cs and CS or from the shading of the pattern set
by scn and SCN or drawn by sh, and the ink of the color space of the
text between two paths. When the spaces of several inks match a color,
the importer takes the space with the walk's ink names. It applies
these names to a page only when the page uses such an ink and a
counting pass of the interpreter draws as many paths there as the walk
counted. Otherwise the import works as before.

The file now opens with a spot swatch for each ink. Solid fills and
strokes of paths and type, and the stops of gradients on paths, link
to the swatch of their ink, the points of a gradient mesh keep the
ink's color, and PDF export writes each ink as its own separation.
Paint in these inks inside pattern cells, soft masks and type 3 glyphs,
and gradients on type in these inks, still import as RGB. Type in one
of these inks can also import as RGB when type in another Separation or
DeviceN ink is shown with no path drawn between them.
2026-10-16 03:36:20 +07:00
dotCooCoo 16b8aeb6b7 PDF/.ai import: a .ai whose page has a UserUnit opens with its layers at the page's size
A PDF page's UserUnit (PDF 1.6) sets the size of its user space unit
in multiples of 1/72 inch. The PDF reader has applied it since #1894, so
a page with /UserUnit 10 and a 36 × 50.4 media box opens at 360 × 504
pt. A .ai whose editing data has its artboards in the page's own units
then no longer matched its PDF part. It opened from the PDF part,
without its layers, its hidden objects and the art outside its
artboards, and with its artboards in a row instead of where the
editing data puts them. document.pdfInfo gave such a page's size in
points and its boxes in the file's numbers.

Editing data whose first artboard has the page's size in the page's
units, and not in points, is now compared with the PDF part scaled
down by the UserUnit. The document read from it is then scaled up by
the UserUnit, with its stroke weights, dashes, effects and pattern
tiles. When the layers can't be read, the artboard layout taken from
the editing data is scaled the same way. document.pdfInfo gives the
boxes in points, as it gives the size.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 76888366e7 AI import: a mesh patch's anchors run in the mesh's direction whatever their key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 1682d59b65 AI import: a gradient mesh patch's edges bend through the anchors listed between its corners
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:20 +07:00
EricKuckandClaude Opus 5.5 6545d8b886 AI import: a drawn look that is an image stands for its object, as one of paths does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:19 +07:00
EricKuckandClaude Opus 5.5 fb6fc437c3 AI import: a clipping path alone in a group clips the clipping group around it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:19 +07:00
midasmatterhorn d5a672fda2 eps: an opacity mask in the editing data is named, not drawn whole (#2004) 2026-10-16 03:36:19 +07:00
bflatastic 312a939646 eps: rustfmt the layer transparency change 2026-10-16 03:36:19 +07:00
NetterSilverandClaude Haiku 5.5 dfdb98463f Open PDFs and .ai files with an image over 256 MB decoded
Fixes #2005

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:36:19 +07:00
midasmatterhorn 6feec01d85 eps: a layer's own transparency in the editing data is read (#2003) 2026-10-16 03:36:19 +07:00
BFlat 2cdd00cf7b Merge pull request #2278 from storytold/fix/properties-flip-labels
Properties: the flip buttons keep the reference app's Properties names (#1910)
2026-10-16 03:23:19 +07:00
BFlat 8958dfe60a Merge pull request #2274 from dotCooCoo/feat/type-reset-tracking
Type: Cmd/Ctrl+Alt+Q resets tracking to 0 and kerning to Auto
2026-10-16 03:22:55 +07:00
BFlat e5c12d0487 Merge pull request #2272 from dotCooCoo/feat/layers-eye-outline-others
Layers: Ctrl/Cmd+Alt-click on an eye switches the other layers between Outline and Preview
2026-10-16 03:22:39 +07:00
BFlat e797b68220 Merge pull request #1920 from sanyamsingh001/codex/r65-width-finite
G1: Fix intermediate overflow in adjoining width adjustments
2026-10-16 03:22:21 +07:00
BFlat f4b0e0f27b Merge pull request #1926 from marcoarnulfo/fix/paste-in-place-off-artboard
Paste in Place: art copied from off the artboards goes back where it was
2026-10-16 03:22:02 +07:00
BFlat 2480017af0 Merge pull request #1939 from dotCooCoo/fix/issue-1692-hex-field-tab
Fix #1692: tabbing through a hex field no longer resets the color
2026-10-16 03:21:46 +07:00
BFlat 312456a769 Merge pull request #1937 from dotCooCoo/fix/issue-1692-tab-order
Part of #1692: Tab in the Color Picker goes through one color model's fields before the next
2026-10-16 03:21:29 +07:00
BFlat bbda88bc71 Merge pull request #1951 from marcoarnulfo/fix/type-color-while-typing
Type: a colour picked while typing keeps what was typed
2026-10-16 03:21:13 +07:00
BFlat a324b0e25f Merge pull request #1967 from switch87/fix/1952-optional-number-strings
Transform commands: a string for an optional number is an error (#1952)
2026-10-16 03:20:58 +07:00
BFlat 39faa02fe5 Merge pull request #1988 from MarekSpodBiedry/issue-1970
Paint: picking another pattern swatch keeps the pattern transform (#1970)
2026-10-16 03:20:40 +07:00
BFlat 57ec0e6d7f Merge pull request #2047 from AyberkA0/i18n-eps-layers-note
i18n: the notes for an EPS or .ai whose layers weren't read are translated (#2035)
2026-10-16 03:20:23 +07:00
BFlat 6341519444 Merge pull request #2048 from AyberkA0/doc-loadprofile-lifetime
color.loadProfile: say that a loaded profile lasts for the run (#1955)
2026-10-16 03:20:07 +07:00
BFlat d4fa50f262 Merge pull request #2146 from AyberkA0/add-anchor-plus
Shortcuts: + selects the Add Anchor Point tool (#2142)
2026-10-16 03:19:50 +07:00
BFlat 6c4804383a Merge pull request #2155 from dotCooCoo/fix/inflate-one-edge-index
3D and Materials: Inflate builds its edge index once
2026-10-16 03:19:34 +07:00
BFlat 263fcff7b1 Merge pull request #2157 from dotCooCoo/fix/export-params-list-ai
Export: the export commands' params and the MCP export tool list the .ai format
2026-10-16 03:19:18 +07:00
BFlat 3c9fda57df Merge pull request #2183 from dotCooCoo/fix/export-for-screens-formats-text
Export for Screens: the params text and the format error list every format a row writes
2026-10-16 03:19:03 +07:00
BFlat 59dfe0834c Merge pull request #2198 from marcoarnulfo/fix/pdf-title-document-name
PDF: an untitled document saved to a file exports titled after it
2026-10-16 03:18:47 +07:00
BFlat b70ba0f82b Merge pull request #2255 from dotCooCoo/docs/print-system-service
Docs: the Print section describes printing through the system's print service
2026-10-16 03:18:31 +07:00
BFlat 3a0d1ce970 Merge pull request #2261 from dotCooCoo/fix/ru-isolate-latin-letter
i18n: Russian Isolate and Sublayer rows use Cyrillic letters only
2026-10-16 03:18:14 +07:00
BFlat 6048fd6b8e Merge pull request #2275 from thejdubb02/fix/cmyk-page-group-isolated
PDF export: a CMYK page without blending through is an isolated group (#2156)
2026-10-16 03:17:57 +07:00
BFlat e479e62317 Merge pull request #2271 from gorkematlyn/upstream-pdf-panic
PDF.R1: never panic on a damaged array or dictionary in an imported PDF
2026-10-16 03:17:40 +07:00
BFlat 8f1d730ccb Merge pull request #2214 from dotCooCoo/feat/outline-object-type-art
Type: type drawn as glyph outlines keeps its underline, stroke weight, gradients and patterns
2026-10-16 03:17:22 +07:00
BFlat 043f2dff3c Merge pull request #2215 from dotCooCoo/fix/export-selection-keeps-effects
Export Selection: the crop takes in what each object's effects draw
2026-10-16 03:17:05 +07:00
BFlat b8d1bdea40 Merge pull request #2219 from dotCooCoo/feat/print-fallback-font-warning
Print: Print and Print Preview warn when type prints in the fallback font
2026-10-16 03:16:49 +07:00
BFlat 8ca5615dca Merge pull request #2242 from dotCooCoo/fix/data-set-sets-objects-in-groups
Variables: selecting a data set sets the objects inside a group that it shows
2026-10-16 03:16:35 +07:00
BFlat 0a0d22df57 Merge pull request #2250 from dotCooCoo/fix/variable-binding-errors
Variables: binding errors name the command and say when a layer is hidden
2026-10-16 03:16:19 +07:00
BFlat f3eab99283 Merge pull request #2226 from dotCooCoo/feat/update-data-set-keeps-own-values
Variables: Update Data Set writes only the variables the active data set holds
2026-10-16 03:16:03 +07:00
BFlat f253d2b3e3 Merge pull request #2229 from dotCooCoo/fix/pen-cursor-asterisk
Pen: the new-path asterisk leaves the pointer once a path is started
2026-10-16 03:15:47 +07:00
BFlat 70723a799b Merge pull request #2234 from dotCooCoo/feat/proof-setup-tritanopia
View: Proof Setup simulates tritanopia
2026-10-16 03:15:32 +07:00
BFlat 9bbe74bd19 Merge pull request #2236 from dotCooCoo/feat/transform-alt-enter-copy
Transform: Alt+Enter applies a value to a copy of the selection
2026-10-16 03:15:17 +07:00
BFlat f3363bddc2 Merge pull request #2246 from dotCooCoo/feat/pencil-keep-selected
Pencil and Paintbrush: with Keep selected off, a drawn path is not left selected
2026-10-16 03:14:56 +07:00
BFlat 80f084d8cf Merge pull request #2247 from dotCooCoo/feat/new-swatch-global-process
Swatches: Ctrl/Cmd+Shift-click on New Swatch makes a global process color
2026-10-16 03:14:41 +07:00
BFlat f614428a92 Merge pull request #2252 from dotCooCoo/feat/drop-shadow-darkness
Effects: Drop Shadow has Darkness, the object's own colors with black added
2026-10-16 03:14:23 +07:00
BFlat 7f899ef293 Merge pull request #2254 from dotCooCoo/feat/shift-tab-panels
Window: Shift+Tab shows and hides the panels but leaves the toolbar and the Control bar
2026-10-16 03:14:03 +07:00
BFlat e3e290d43b Merge pull request #2256 from dotCooCoo/feat/draw-inside-double-click
Draw Inside: a double-click outside the object returns to Draw Normal
2026-10-16 03:13:41 +07:00
BFlat b069019cdd Merge pull request #2257 from dotCooCoo/feat/sample-color-from-screen
Color: Sample Color from Screen picks a color anywhere on the screen
2026-10-16 03:13:25 +07:00
BFlat e55122afc5 Merge pull request #2258 from dotCooCoo/feat/control-bar-isolation
Control bar: buttons isolate the selected object and exit isolation mode
2026-10-16 03:13:08 +07:00
bflatastic 73f3731dbf Properties: the flip buttons keep the reference app's Properties names (#1910)
#2227 renamed them Flip Horizontal and Flip Vertical; what they do is unchanged. The reference app's Properties panel calls them Flip Along Horizontal Axis and Flip Along Vertical Axis (the Transform panel's menu keeps Flip Horizontal and Flip Vertical), so the labels and their catalog rows come back.
2026-10-16 02:56:55 +07:00
bflatastic 5e374712dc i18n(bg): Reset Tracking and Kerning 2026-10-16 02:56:55 +07:00
dotCooCoo d726f6596c Layers: Ctrl/Cmd+Alt-click on an eye switches the other layers between Outline and Preview
A Ctrl/Cmd+Alt-click on a row's eye in the Layers panel ran Hide Others
or Show All Layers, as an Alt-click does. It now runs Outline Others for
the row's layer (layer.outlineOthers), or Preview All Layers
(layer.previewAll) when every other top-level layer is in Outline
already. Each is one undo step. Alt-click and Ctrl/Cmd-click on an eye
keep their actions.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:56:35 +07:00
sanyamsingh001andCodex 98511401a0 G1: Fix intermediate overflow in adjoining width adjustments
Co-authored-by: Codex <[email protected]>
2026-10-16 02:56:35 +07:00
Marco Arnulfo f2f1945973 Paste in Place: art copied from off the artboards goes back where it was (fixes #1924) 2026-10-16 02:56:35 +07:00
dotCooCoo d35ed26b0f Fix #1692: tabbing through a hex field no longer resets the color
In the Color Picker, Tab out of an edited field gives the next field the
focus in the same frame, and the picker applies the edit once it has
drawn its fields. The # field after Y took the focus showing the color
from before the edit and kept that text while it had the focus, so the
next Tab committed it and reset every other field. Shift+Tab into the #
field from K had the same effect. Swatch Options and New Swatch draw a
copy of the field, and Tab from the last component field into it had the
same effect there.

widgets::hex_field and the Swatch Options copy now show the current
color until something is typed into them, also while they have the
focus, and commit only what was typed. The change covers the # fields of
the Color Picker, Swatch Options, New Swatch, the Color panel, Color
Themes, Recolor Artwork, Document Setup and Define Perspective Grid.

widgets::opt_field, which draws the Stroke panel's dash and gap fields,
kept its text the same way: passing through a blank gap after typing a
dash committed the blank, and the panel applied the same dash pattern
again. It gets the same change. When its value changes while it has the
focus and nothing has been typed into it, all of the new value is
selected, as on keyboard focus.
2026-10-16 02:56:35 +07:00
dotCooCoo aaa7359c7b Part of #1692: Tab in the Color Picker goes through one color model's fields before the next
The Color Picker drew its value fields as one grid, row by row. Tab
moves through the fields in the order they are drawn, so it went H, L,
S, a, B, b, R, C, G, M, B, Y, # and K, from one color model to the
other on every row.

HSB, RGB and the hex field are now in one grid, and Lab and CMYK are in
a second grid beside it. Tab goes through H, S, B, R, G, B and #, then
L, a, b, C, M, Y and K. The dialog looks as before: K's row is as tall
as the hex field's row beside it.
2026-10-16 02:56:35 +07:00
Marco Arnulfo 7608639021 Type: a colour picked while typing keeps what was typed (fixes #1950)
The Color panel and the Color Picker preview a colour in an interaction.
While typing, that interaction joined the open typing session and
previewed from the document before the typing, so new type came back as
its placeholder text. Typing in progress is now kept as its own step
before another interaction begins, as Undo already does.
2026-10-16 02:56:35 +07:00
Gert Pellin e6170de1a2 Transform commands: a string for an optional number is an error (#1952)
object.setBounds read x, y, width and height with f64_or, which falls back
to the current value for anything that isn't a JSON number, so
{"width": "60 mm"} returned ok and changed nothing. object.move,
object.nudge, object.rotate, object.scale (sy) and object.shear did the
same with their optional numbers. A value that is neither a number nor
null is now a BadParams error naming the key, as a missing required
number already is.
2026-10-16 02:56:35 +07:00
MarekSpodBiedry a0f8a88e82 Paint: picking another pattern swatch keeps the pattern transform (#1970) 2026-10-16 02:56:35 +07:00
Ayberk A. 6d898558fd i18n: the notes for an EPS or .ai whose layers weren't read are translated (#2035)
The importer wraps its reason in "the file's layers weren't read ({why}):
it comes in as its page, in one layer" (and, for an .ai, "... from its
editing data ({why}): they come from its PDF part"). The reasons had @msg
rows, the wrappers none, so after "Opened with 1 note(s):" the whole note
stayed English. The two templates are now in every language that
translates messages (and pt-br, which has the reasons), and the reason
inside is translated through them. These notes are pushed as warnings, so
the message scan doesn't see them: a test checks them in each language.

"<Clip Group>" already has a French entry on main; layer names such as
"Layer 1" are stored names and stay as they are.
2026-10-16 02:56:35 +07:00
Ayberk A. 5ff34782cb color.loadProfile: say that a loaded profile lasts for the run (#1955)
The profile is registered in the process's colour registry only, so the
next `vectorcraft-cli run` (or the next launch of the app) doesn't know it
and edit.colorSettings falls back to the built-in profile. The command's
description now says so, and that the profile has to be loaded again in
each run that names it. Keeping profiles across runs would need a place in
the preferences to remember them, which is left for the maintainers.
2026-10-16 02:56:35 +07:00
Ayberk A. 07c6b938bc Shortcuts: + selects the Add Anchor Point tool (#2142)
The tool's default key is "+", and `shortcuts::parse` split a bare "+" as a
separator with nothing on either side: the key part came out empty, so the
shortcut didn't parse, `tool_for_key` found no tool for a typed "+" and the
key did nothing. "-" (Delete Anchor Point) has no separator to trip on, which
is why it worked. A bare "+" is now the key itself, the "=" key, as "Cmd++"
already was, so "+" typed with Shift on a US layout, by a layout's own "+"
key or on the numpad selects the tool.
2026-10-16 02:56:35 +07:00
dotCooCoo cb9522f7cd Type: Cmd/Ctrl+Alt+Q resets tracking to 0 and kerning to Auto
The reference app resets tracking and kerning to 0 with Cmd/Ctrl+Alt+Q.
VectorCraft had no command for it.

type.resetTracking (Reset Tracking and Kerning, default Cmd+Alt+Q) sets
tracking to 0 and kerning to Auto, which the Character panel shows as 0,
in the text the Type tool has selected, or else in all the text of
every selected type object. At a caret it resets only the kerning of
the character before it, where Alt+Left/Right kerns. It is one undo
step. The range logic of type.step moves into a shared type_ranges.

When a call gives ids, type.step and type.resetTracking change all the
text of those type objects even while the Type tool edits text, as
paint.setFill does. Before, type.step changed the Type tool's selected
text instead.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:56:35 +07:00
dotCooCoo 0155eaf468 3D and Materials: Inflate builds its edge index once
inflate built an edge index (Edges) of the shape's rings, and
Membrane::solve, which inflate calls first, built a second index of the
same rings. Membrane::solve now takes the index that inflate builds.

In a release build, one Edges::new takes 0.08 ms of the 27 ms that
inflate takes on a ring of 2,000 points, and 2.0 ms of 219 ms on a ring
of 1,000 spikes whose 2,000 edges fill 693,816 bucket entries. The
facets do not change: for nine shapes, three views and 96 combinations
of the Inflate settings, the Debug text of all 2,592 results is byte
for byte the same on main and with the change.
2026-10-16 02:56:34 +07:00
dotCooCoo 5a83bcfafa Export: the export commands' params and the MCP export tool list the .ai format
document.export, document.serialize and document.exportSelection write
a PDF-compatible .ai (format: "ai"), and document.formats lists ai as
writable, but none of their params texts named it. The params text of
document.exportSelection named only png, jpg, webp, svg, svgz and pdf,
although the command writes all 20 writable formats. The description
of the MCP export tool and its row in docs/mcp.md named every format
of the tool's format enum except ai, and the range text and the row
said that only PDF writes several artboards.

The three params texts now list every format their command writes,
and document.export's range and useArtboards texts name .ai beside
PDF. The MCP export tool's description names ai, and its artboard and
range texts say that .ai, like PDF, writes one page per artboard. The
range text and the row also say that SVG, SVGZ and EPS write one file
per chosen artboard, and that rasters, DXF, EMF and WMF do so with
options.useArtboards: true.

Docs: docs/mcp.md (the export tool).
2026-10-16 02:56:34 +07:00
dotCooCoo 66dbe40ea6 Export for Screens: the params text and the format error list every format a row writes
The document.exportForScreens params text listed png, png8, jpg, webp,
gif, svg, svgz and pdf for a row's format, and its settings keys left
out svgz. A row of another format VectorCraft writes, such as eps, was
refused with "Export for Screens writes png, png8, jpg, webp, gif, svg,
svgz or pdf". Rows of tiff, bmp, tga and psd are written too, and the
Export for Screens dialog offers TIFF rows.

The params text, its settings keys and the error now list the 12
formats a row writes: png, png8, jpg, webp, gif, tiff, bmp, tga, psd,
svg, svgz and pdf. Which formats a row accepts does not change.
2026-10-16 02:56:34 +07:00
Marco Arnulfo 53c59f06de PDF: an untitled document saved to a file exports titled after it (fixes #2166) 2026-10-16 02:56:34 +07:00
dotCooCoo 513685a221 Docs: the Print section describes printing through the system's print service
The Print section of docs/mcp.md said that File → Print writes the job
as a print-ready PDF and that there is no printer driver. The desktop
app sends the PDF to the system's print service, the web build opens
the browser's print dialog on it, and the PDF File entry in the
Printer list saves it. The section now says so, points to Printing,
and marks the `file.print` reply that follows as the headless one.
2026-10-16 02:56:34 +07:00
dotCooCoo 4a1323ad07 i18n: Russian Isolate and Sublayer rows use Cyrillic letters only
The Russian catalog's Isolate row began with a Latin "A" (U+0041),
"Aизолировать", and the Create New Sublayer and New Sublayer… rows
spelled "подслой" with a Latin "c" (U+0063). The rows now read
"Изолировать", "Создать новый подслой" and "Новый подслой…".

A new test fails when a word in any catalog's translation holds letters
from more than one of the Latin, Cyrillic and Greek scripts.
2026-10-16 02:56:34 +07:00
Justin Willhite 8da47f11f8 PDF export: a CMYK page without blending through is an isolated group (#2156) 2026-10-16 02:56:34 +07:00
bflatastic 18f8a1bdcf PDF import: a damaged array is refused as damaged, not as functions built on one another 2026-10-16 02:56:34 +07:00
Claude 95d27fe390 PDF.R1: never panic on a damaged array or dictionary in an imported PDF
hayro-syntax's Dict::entries still unwraps each value it reads, so a
dictionary cut short (or with a stray token) aborts the import. The
craft-libs ArrayIter now stops at the first unreadable item instead of
panicking, which also means a hostile item after a self-referencing
function or colour space is silently dropped from nests_without_end.

- import_color: `readable(array)` checks every item parses; `items()`
  yields nothing for an unreadable array; `entries()` reads a dictionary
  key by key through get_raw, skipping values that don't parse.
- pages/editing/import_scan: use the helpers instead of raw_iter() and
  Dict::entries().
- nests_without_end refuses a file with an unreadable array.
- Tests: two refusal cases in pdf tests and an .ai cut inside an array
  or dictionary in engine/tests/import_fuzz.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
2026-10-16 02:56:34 +07:00
bflatastic ff7f47119d i18n(bg): rows for the strings this batch adds (Darkness, Isolate Selected Object, Keep selected, Sample Color from Screen, Tritanopia, Panels Except Tools and Control) 2026-10-16 02:56:32 +07:00
BFlat 01c66e51b7 Merge pull request #2016 from EricKuck/fix/ai-pattern-phase
AI import: a pattern fill tiles from its tile's lower left, wherever the definition drew the tile
2026-10-16 02:38:08 +07:00
BFlat 23a0a5f857 Merge pull request #2015 from EricKuck/fix/ai-non-native-art-orientation
AI import: non-native art stays upright unless its placement mirrors it
2026-10-16 02:37:52 +07:00
BFlat 651e2e1a57 Merge pull request #2014 from EricKuck/fix/export-art-over-artboard-groups
SVG export: a chosen artboard's file leaves out the art of other artboards inside a shared group
2026-10-16 02:37:38 +07:00
BFlat 5a5bb73aa9 Merge pull request #2062 from dotCooCoo/fix/page-knockout-group-backdrop
Render: a knockout group drawn over its backdrop counts an element's partial coverage once
2026-10-16 02:37:22 +07:00
BFlat 88e950a0d7 Merge pull request #2073 from dotCooCoo/feat/outline-object-nested-type
Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility
2026-10-16 02:37:05 +07:00
BFlat 99bdcc6633 Merge pull request #2232 from MarekSpodBiedry/issue-2086
Menus: keep scroll bar clear of shortcuts and stop above the status bar (#2086)
2026-10-16 02:36:44 +07:00
BFlat ef53890c28 Merge pull request #2109 from dotCooCoo/fix/area-type-rows-columns-overflow
Type: area type opens and lays out with at most 100 rows and 100 columns
2026-10-16 02:36:23 +07:00
BFlat 011a1a0314 Merge pull request #2108 from dotCooCoo/fix/issue-1770-rosette-spurs
Fix #1770: Shape Builder merges leave no spur or split outline when shared edges differ by rounding
2026-10-16 02:36:05 +07:00
BFlat 17c512f5c9 Merge pull request #2055 from dotCooCoo/fix/copied-artboard-free-id
Artboards: copied artboards take a free id and keep their own guides
2026-10-16 02:35:47 +07:00
BFlat ec602d7e27 Merge pull request #2114 from dotCooCoo/fix/moved-rasters-test-fonts-steady
Render tests: two moved raster tests no longer run alongside the tests that add fonts
2026-10-16 02:35:30 +07:00
BFlat bb5f16c101 Merge pull request #2118 from dotCooCoo/fix/alt-drag-tests-created-stamp
Tests: the Alt-drag tests no longer fail when their documents are made in different seconds
2026-10-16 02:35:13 +07:00
BFlat b8041d3745 Merge pull request #2235 from Messhias/fix/2094-create-outlines-shortcut
Fix #2094: Cmd+Shift+O Create Outlines while typing
2026-10-16 02:34:55 +07:00
BFlat 0205a3332f Merge pull request #2227 from Messhias/fix/1910-reflect-axis
fix(G11): Reflect X/Y flip the correct axes (#1910)
2026-10-16 02:34:42 +07:00
BFlat a7425f27c1 Merge pull request #2176 from Silvertree2010/fix/2078-when-i-click-pixels-the-unit-of
Make New Document preset cards follow the chosen units
2026-10-16 02:34:25 +07:00
BFlat fb803dbdae Merge pull request #2158 from Silvertree2010/fix/2084-the-orientation-selection-is-wro
Fix New Document orientation on category tabs and preset cards
2026-10-16 02:34:08 +07:00
BFlat 81420b87c8 Merge pull request #2204 from Silvertree2010/fix/h1429-svg-export-writes-control-charac
Make SVG export of type with control characters well-formed
2026-10-16 02:33:51 +07:00
BFlat 57b5931c83 Merge pull request #2022 from Silvertree2010/fix/h797-negative-picas-points-value-2p6
Apply the sign of a picas+points value to the whole value
2026-10-16 02:33:32 +07:00
BFlat ce02a42f35 Merge pull request #2069 from Silvertree2010/fix/h839-field-arithmetic-rejects-followe
Evaluate * and / left to right in field arithmetic
2026-10-16 02:33:15 +07:00
BFlat 4b60fcead7 Merge pull request #2209 from Silvertree2010/fix/h1425-field-arithmetic-rejects-a-negat
Accept a negative operand after an operator in field arithmetic
2026-10-16 02:32:59 +07:00
dotCooCoo 3bef84aaa6 Type: type drawn as glyph outlines keeps its underline, stroke weight, gradients and patterns
Type is drawn as its glyph outlines under a geometry effect, in EMF and
WMF export, in DXF export when it is written as outlines, through
Flatten Transparency with Convert All Text to Outlines, and inside an
envelope. Those outlines differed from the type the canvas draws in
four ways. They had no underline or strikethrough bars. A character
stroke on scaled type kept its unscaled weight. An unplaced gradient was
fitted to the glyphs it painted instead of the layout box. A run's
pattern tiles stayed in document space instead of scaling and turning
with the type. A group's or layer's own fills and strokes also painted
the glyphs of the type in it without the bars.

The outlines now include each run's underline and strikethrough bars,
as the canvas has drawn them since #850. Each run's path is made in
text space with its unplaced gradients fixed to the layout box, then
placed by the type's transform, which also scales its stroke weight
and dashes and transforms its pattern tiles. The type's own fills and
strokes have their unplaced gradients fixed to the bounding box of the
layout box in the document, where the canvas fits them.

Geometry effects that move or scale the outlines move and scale the
type's gradients with them: the type's own effects, also inside an
envelope and after Flatten Transparency has converted the type to
outlines, and the effects of a group, symbol instance or envelope that
holds the type. outline_text marks its group with a new node field,
type_outlines, which is saved with the document. reshape_leaves maps
the placed gradients in each marked group through the map that takes
the group's bounds before the effects onto its bounds after them. A
scale out of proportion stretches a radial gradient into an ellipse
and turns the isolines of a diagonal linear gradient, as it does on
type scaled by its own transform. Placed gradients outside those
groups stay where they are.
2026-10-16 02:26:15 +07:00
dotCooCoo 98d1d738a2 Export Selection: the crop takes in what each object's effects draw
Export Selection, Asset Export, the Assets tab of Export for Screens
and the rasterized art of CSS Properties cropped each object to its
visual bounds, which leave out live effects. A path with an 8 pt drop
shadow was exported with the shadow cut off at the path's edge, and
a group's own shadow was cut off the same way.

The crop is now the bounds the renderer measures for what it draws
(`vectorcraft_render::effect_bounds`): `painted_bounds` without its
room for brush art. A brushed path is cropped to its visual bounds as
before, and a Shaper group leaves out its transparent source art as
its visual bounds do. `assets.list` reports the same crop. The
renderer measures a shadow's or glow's reach on every side of the
object, so the image also has transparent room on the side away from
an offset shadow.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:26:14 +07:00
dotCooCoo 1c30cbf88c Print: Print and Print Preview warn when type prints in the fallback font
PDF, EPS, EMF/WMF and raster exports, and SVG exports that outline or
embed fonts, warn when type is set in a font family that isn't
available, but file.print and print.preview gave no such warning. A job
in a missing family printed in Source Sans 3 with no warning, and the
Print dialog's Summary showed none.

Both commands now add the exports' warning for the type that prints:
visible type that fits its frame or path, on the layers that Print
Layers picks, where what it paints (its effects applied) lands on the
paper of an artboard or tile that prints, inside its clipping paths.
The type in the symbols, inline graphics, patterns, scatter brushes,
opacity masks and placed documents of the art that prints counts too.
file.print reads the fonts before raster effects and Print as Bitmap
turn type into images, for PDF and PostScript output.

Docs: docs/mcp.md (Print), docs/target-app-parity.md (Fonts).
2026-10-16 02:26:13 +07:00
dotCooCoo d069f0fe98 Variables: selecting a data set sets the objects inside a group that it shows
dataset.select applied a row's values in the order the variables were
defined, and it skips an object inside a hidden group. When a row
showed a group and set an object inside it, and the object's variable
was defined first, the object was skipped while the group was still
hidden: one selection applied the row in part, and selecting the row
again applied the rest. A text variable on type inside the group was
skipped the same way, and a row that hid a group skipped the objects
inside it whose variables were defined after the group's.

dataset.select, dataset.next and dataset.prev now apply the visibility
values that show objects first, outermost object first, then the other
values in definition order, then the visibility values that hide
objects, innermost object first. Of two variables of the same kind on
one object, the one defined last still wins.

Docs: docs/mcp.md.
2026-10-16 02:26:11 +07:00
dotCooCoo 01b3599227 Variables: binding errors name the command and say when a layer is hidden
Binding art in a hidden group or layer failed with "node N is locked",
even when nothing was locked. The error now says whether the object is
locked, is in a locked group or layer, or is in a hidden group or layer.

Make Text Dynamic and Make Visibility Dynamic reported their errors under
`variable.makeDynamic`, which is not a command id. They now name
`variable.makeTextDynamic` or `variable.makeVisibilityDynamic`.
2026-10-16 02:26:09 +07:00
dotCooCoo 00b02c1bdb Variables: Update Data Set writes only the variables the active data set holds
A data set can hold values for only some variables: `dataset.new` and
`dataset.set` store only the values they are given, and selecting the
data set leaves the other variables alone. Update Data Set replaced the
active data set's values with the values the art reports for every
variable. After one Update, a data set that set only a visibility
variable also held the current text of the bound type, and selecting it
again wrote that text back. Update also dropped a variable's value when
its objects were locked or inside a hidden or locked layer or group,
because such objects report no value.

`dataset.update` now writes the art's current value for each variable
the active data set holds, and keeps the data set's value when the art
reports none. A variable that the data set does not hold stays out of
it, including one defined after the data set was captured;
`dataset.update {all: true}` also adds the other variables that the art
reports a value for. The reply adds `left`, the variables it did not
write. The Variables panel's Update Data Set button and menu item run
it without `all`.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:26:08 +07:00
dotCooCoo ff33e0556f Pen: the new-path asterisk leaves the pointer once a path is started
The Pen's pointer showed the new-path asterisk before the first anchor
and also while a path was being drawn, where a click adds the path's
next anchor. A new Cursor::PenDrawing, the pen nib without a badge, now
shows there. The close, convert and join badges still show over the
first anchor, the last anchor and another open path's end. The asterisk
shows only where a click starts a new path.

The Continue, Add and Delete badges now also show after another path
was selected while the Pen was still drawing (for example with a
Cmd-click). A click there already continued the path or added or
deleted an anchor, but the pointer showed the asterisk.
2026-10-16 02:26:06 +07:00
dotCooCoo 35f43d631f View: Proof Setup simulates tritanopia
View › Proof Setup had two color blindness items, protanopia and
deuteranopia. A third item, Color blindness – Tritanopia-type, proofs
the canvas with a tritanopia simulation, and the Separations Preview
panel's Proof Setup list offers it too. `view.proofSetup` takes
`target: "tritanopia"`.

The simulation follows Brettel, Viénot and Mollon (1997): a color
keeps its L and M cone responses, and its S response is replaced so
that it lies on one of two half-planes. Both contain the neutral
axis; one passes through the 485 nm color and the other through the
660 nm color. The neutral axis runs to the display's white, so grays
keep their value. The matrices are computed at
compile time from the published inputs cited in the code. The
protanopia and deuteranopia matrices are unchanged.
2026-10-16 02:26:04 +07:00
dotCooCoo bbad808a02 Transform: Alt+Enter applies a value to a copy of the selection
In the Transform panel, the Properties panel's Transform section and
the Control bar, Alt+Enter (Option+Return) in X, Y, W, H, Rotate or
Shear ended editing as Enter does, and the selection itself moved,
resized, turned or sheared. Now the field's command gets `copy: true`
when the Enter that committed it was pressed with Alt. The copy takes
the value, the original stays where it was, and the copy is selected,
in one undo step. A press elsewhere with Alt held applies the value
without a copy. With anchor points selected with Direct Selection,
Alt+Enter applies the value to them, as Enter does.

- widgets::enter_modifiers returns the modifiers of the frame's Enter
  press.
- transform::alt_enter_copies gives the `copy` param of the three
  surfaces' commands.

Docs: docs/target-app-parity.md.
2026-10-16 02:26:01 +07:00
dotCooCoo d68e14a393 Pencil and Paintbrush: with Keep selected off, a drawn path is not left selected
A Pencil or Paintbrush stroke always left the path it drew or continued
selected. The reference app's Pencil and Paintbrush options have Keep
selected for this, and VectorCraft's Tool Options had no such row.

The Pencil and Paintbrush Tool Options now have a Keep selected
checkbox, on by default, which keeps today's behavior. When it is off,
a new path is not selected and the selection stays as it was, and a
continued path is removed from the selection while the rest stays. The
stroke is still one undo step. The option persists like the tools'
other options, and path.freehand and brush.freehand take keepSelected.

Docs: docs/target-app-parity.md, docs/ui-parity.md, docs/control-protocol.md.
2026-10-16 02:26:00 +07:00
dotCooCoo 4d63227223 Swatches: Ctrl/Cmd+Shift-click on New Swatch makes a global process color
Ctrl/Cmd+Shift-clicking New Swatch in the Swatches panel opened New
Swatch for a spot color, as a Ctrl/Cmd-click does. It now opens the
dialog for a global process color, and with Alt also held it saves one
at once. `ui.newSwatch` takes `global`.

An Alt+Ctrl/Cmd-click with a gradient or pattern as the active paint
failed with "spot, mode and group apply to solid colours only". It now
saves a gradient or pattern swatch, as a Ctrl/Cmd-click does through
the dialog.
2026-10-16 02:25:59 +07:00
dotCooCoo 720ce5ed73 Effects: Drop Shadow has Darkness, the object's own colors with black added
The reference app's Drop Shadow has a Color and a Darkness, the
percentage of black added to the object's own colors. Drop Shadow had
only a Color, so every shadow was one color. Choosing one or the other,
and the defaults, are VectorCraft's own.

Drop Shadow now takes `shade` ("color" or "darkness", default "color")
and `darkness` (%, default 100). Its dialog and the Appearance panel's
inline editor show Color and Darkness as radio buttons. With Darkness
d, a CMYK document keeps the object's cyan, magenta and yellow and
raises its black from K to K + d × (1 − K), and an RGB document
multiplies each channel by 1 − d, so in RGB 0% gives the object's
color and 100% gives black. A shadow saved without these keys draws as
before. SVG export writes a Darkness shadow with feComponentTransfer
and SVG import reads it back; CSS export leaves it out.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:58 +07:00
dotCooCoo b5a0fee1ea Window: Shift+Tab shows and hides the panels but leaves the toolbar and the Control bar
Shift+Tab ran Tab's command and hid the toolbar and the Control bar
along with the panels. egui matches a chord without Shift when Shift
is also held.

A new UI command, window.panels (default Shift+Tab), shows and hides
the dock and the floating panels and leaves the toolbar and the
Control bar as they are. The shortcut table tries chords with more
modifiers first, so Tab's chord no longer takes Shift+Tab. Hiding the
panels with Tab or Shift+Tab also closes a panel popped out of the
icon column, which stayed on screen at the window's right edge.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:56 +07:00
dotCooCoo 103c6ebdfb Draw Inside: a double-click outside the object returns to Draw Normal
In Draw Inside, a double-click with the Selection, Direct Selection or
Group Selection tool outside the object being drawn into now sets the
drawing mode to Draw Normal (view.drawMode {mode: "normal"}), as
Shift+D does. The object's geometric bounds, grown by the selection
tolerance, count as inside, and a double-click after the object was
deleted also returns to Draw Normal. The tool then takes the
double-click as before: with the Selection tool, a double-click on the
empty canvas still exits isolation mode, and one on another object
still isolates that object. Other tools leave Draw Inside on.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:55 +07:00
dotCooCoo 25df070576 Color: Sample Color from Screen picks a color anywhere on the screen
The Color Picker gets an eyedropper button beside the New and Original
chips, and `ui.sampleScreenColor {stroke?, picker?}` shows the system's
color sampler. The command answers at once. When the person clicks a
color anywhere on the screen, the color becomes the fill or the stroke
through paint.setFill / paint.setStroke as one undo step, or with
`picker` it becomes the open Color Picker's color, which previews on the
selection as any pick does. Escape cancels the sampler. The sampled sRGB
color goes through Cms::srgb_to_rgb, so in a Display P3 working space a
color inside sRGB shows as it did on the screen. `ui.inspect` reports
`screenSampler` while the sampler is open.

- macOS uses NSColorSampler (apps/vectorcraft/src/mac_screen_color.rs).
- The web app uses the browser's EyeDropper API where the browser has
  one.
- Without a sampler (Windows, Linux, other browsers) the button is gray
  and the command returns an error. Keyboard Shortcuts lists the
  command, so it can be given a key.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-16 02:25:53 +07:00
dotCooCoo a14abc3177 Control bar: buttons isolate the selected object and exit isolation mode
The Control bar had no isolation controls. In isolation mode it now
shows an Exit Isolation Mode button (`object.exitIsolation`), except
while a pattern is edited: the isolation bar's Done and Cancel end
that edit. Outside isolation mode, with one group, compound path,
path, image, mesh or placed file selected, it shows an Isolate
Selected Object button (`object.isolate`). After a Direct Selection
click on a path inside a group, the button isolates that path. An
envelope's contents get no button: for them the Control bar shows the
envelope's Edit Envelope and Edit Contents buttons.

The button offers the kinds of object that the Selection tool's
double-click isolates. The double-click's check moves unchanged into
`vectorcraft_tools::select::isolates`, and both use it.

Docs: docs/target-app-parity.md.
2026-10-16 02:25:49 +07:00
EricKuckandClaude Opus 5.5 a3081b9f53 AI import: a pattern fill tiles from its tile's lower left, wherever the definition drew the tile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:47 +07:00
EricKuckandClaude Opus 5.5 710b6058ca AI import: non-native art stays upright unless its placement mirrors it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:47 +07:00
EricKuckandClaude Opus 5.5 0a4e9ea8c8 SVG export: a chosen artboard's file leaves out the art of other artboards inside a shared group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:47 +07:00
dotCooCoo e22b6a5d45 Render: a knockout group drawn over its backdrop counts an element's partial coverage once
A knockout group that is not isolated, and whose elements blend, is
drawn over a copy of the art below it when the renderer has no layer
open around it. Each element was drawn over the whole copy in a layer,
and the layer was cut to the element's knockout shape with DestIn.
Where the shape covers part of a pixel, the cut counted the element's
coverage a second time. With Page Knockout Group on and Page Isolated
Blending off, the page is drawn this way as soon as an object on it
outside an isolated group has a blend mode other than Normal, on itself
or on a fill or stroke. On a test page, adding a Multiply square
changed the half-covered edge of a blue square over orange from
[115, 115, 141] to [172, 159, 147] on white.

Each element is now drawn over the copy offscreen, over its own
bounds. With the DestOut pass before it, each pixel gets (1 - shape)
times what was below plus shape times the element composited with the
copy at a shape of 1, as ISO 32000-1 section 11.4.6 describes. Pixels
that the shape covers fully or not at all are drawn as before.
2026-10-16 02:21:47 +07:00
dotCooCoo 5473e3a5c5 Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility
Geometry effects on a group or layer, and effects on a symbol instance,
blend or repeat, outline the type inside it. outline_nested_text
replaced each type object with a plain group of its outlines, so the
type lost its opacity, blend mode, opacity mask, knockout shape
option, own effects and perspective, and hidden type was drawn,
exported and made visible by Expand Appearance.

outline_nested_text now replaces type with the art that as_art makes
for it, which is also what reshape makes for type with geometry
effects of its own. That art has the type's own geometry effects and
perspective applied and keeps its name, transparency, opacity mask
and other effects. as_art also keeps the object's visibility and
knockout shape option, so hidden type with a geometry effect of its
own is left out of exports. Type in a clip group's clipping path still
becomes its plain outlines and now keeps its visibility.
2026-10-16 02:21:47 +07:00
MarekSpodBiedry 8e58b9a23f Menus: keep scroll bar clear of shortcuts and stop above the status bar (#2086) 2026-10-16 02:21:47 +07:00
dotCooCoo 0c3a5929c7 Type: area type opens and lays out with at most 100 rows and 100 columns
In a dev build, opening a native file whose area type has 2^60 rows
or 2^60 columns failed with an internal error ("capacity overflow"),
and opening one with 2^32 rows and 2^32 columns failed with "attempt
to multiply with overflow". Text layout reserved room for rows times
columns frame cells. `text.areaOptions` keeps both counts within 1 to
100, but reading a document kept the counts the file gave.

Reading a document now clamps each count to 1 to 100
(`AreaOptions::MAX_ROWS_COLUMNS`): a larger count reads as 100, and 0
reads as 1, as layout already treated it. Text layout clamps both
counts the same way. Area type with 1 to 100 rows and columns lays out
as before.

The cells of a frame now share one copy of the frame's flattened
polygons and one copy of the flattened text wrap shapes. Each cell held
its own copy: in a dev build, a test process that laid out text in 100
rows and 100 columns of a circle frame of radius 1,000,000 pt peaked at
1.33 GB of memory, and now peaks at 100 MB.
2026-10-16 02:21:47 +07:00
dotCooCoo 7960fa722f Fix #1770: Shape Builder merges leave no spur or split outline when shared edges differ by rounding
The Shape Builder merges faces by uniting their contours, and the edges
two faces share cancel out. The sweep can give two faces copies of a
shared edge whose ends lie a few times its tolerance apart, with the
difference left as pieces shorter than the tolerance. The union could
then keep the space between the copies: a hairline that joined a hole
as a spur, or a seam that cut a petal tip off the ring's outer outline
as an outline of its own. On Apple Silicon, turning one petal of the
rosette test by -180 degrees instead of 180 leaves a spur.

A Shape Builder action unites the picked faces first, and then what is
left of each shape. When two copies of a shared edge differ in the
first of these unions that unites the faces' contours, each point of
the faces' contours that lies within 16 times the sweep's tolerance of
a point kept before it now moves onto that point before that union and
each later one, with the handles next to it. A piece left within that
distance of one point is dropped. The planar map joins the ends of
copies of an edge within the same distance. Copies that share their
ends leave at most a hairline contour, which the merge drops as a
sliver. When the copies in that union all match exactly, the unions
take the contours unchanged. Only the Shape Builder's merges and
deletions unite faces this way, so Pathfinder output does not change.
2026-10-16 02:21:46 +07:00
dotCooCoo ac4d69032c Artboards: copied artboards take a free id and keep their own guides
Duplicate Artboards, the Artboard tool's Alt-drag (artboard.move with
copy) and Paste of a copied artboard gave the new artboard the largest
artboard id plus one, saturating. In a document with an artboard id
of 4,294,967,295 (u32::MAX), which only a hand-made file has, the copy
got that same id. Artboard guides record the id of their artboard.
Deleting either of the two artboards with that id then deleted the
guides of both, and guide.list reported the copy's guides on the
other one.

push_artboard_copy now takes the id from Document::next_artboard_id,
as artboard.new and Convert to Artboards do. Since the #1718 fix, that
function returns the smallest free id when the largest is u32::MAX.
In a document whose artboard ids are all below u32::MAX, a copy gets
the same id as before.

Docs: docs/target-app-parity.md.
2026-10-16 02:21:46 +07:00
dotCooCoo 83759c0e94 Render tests: two moved raster tests no longer run alongside the tests that add fonts
The key of a raster made for content that shows a symbol includes the
font generation, and since c348b384 (#1723) the key for content
painted with a pattern includes it too. FontDb::add_font increases
that generation, and the six tests in tests_fontchange.rs call it. Two
tests in fx.rs draw such content and count the rasters moved between
frames: moved_rasters_need_the_same_content_and_effects and
a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting.
Neither held fonts_steady(). When a font test added a font between
their frames, the renderer made the raster again and the test counted
0 rasters moved instead of 1. Under CPU load on Windows, with the font
tests in the same process, the first test failed in 12 of 100 runs and
the second failed in 36 of 70.

Both tests now hold fonts_steady().
translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available
added its font without the font lock that fonts_steady() holds shared,
and it now takes adding_fonts(), as the other five font tests do. With
the change, the first test passed in all 200 runs and the second
passed in all 120 runs under the same load.
2026-10-16 02:21:46 +07:00
dotCooCoo 37c3eb0d01 Tests: the Alt-drag tests no longer fail when their documents are made in different seconds
file.new stamps metadata.created from the clock in whole seconds. Four
Alt-drag tests in tests_xform.rs compare the document under test with
one that a second session builds, and both sessions start from
session()'s file.new. When the clock reached a new second between the
two calls, the documents differed in created, and the test failed.
With a temporary change that made each clock read on a thread at least
one second later than the one before, all four failed.

session() in tests_xform.rs and persp_session() in
tests_persp_select.rs now pass "created": null to file.new, as #1894
did for doc_with_rect() in tests_ids_without_selection.rs. Two new
tests in #2067 compare two documents built on persp_session().
2026-10-16 02:21:46 +07:00
bflatastic f5fcb30745 Type: Option and AltGr still type while Cmd/Ctrl chords don't (#2094) 2026-10-16 02:21:46 +07:00
bflatastic 53eb76f001 Properties: the catalogs drop the flip labels the panel no longer shows 2026-10-16 02:21:46 +07:00
Fabio William ConceiçãoandCursor 2ebc607ed2 Fix #2094: Cmd+Shift+O Create Outlines while typing
Keep Type tool from inserting Text when Cmd/Ctrl/Alt is held so the
chord outlines instead of typing, and cover Selection and Type-tool
paths so File › Open and the Artboard tool cannot steal it.

Co-authored-by: Cursor <[email protected]>
2026-10-16 02:21:46 +07:00
NetterSilverandClaude Haiku 5.5 a8e9b9d86e Make New Document preset cards follow the chosen units
Fixes #2078

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 5162f830d0 Fix New Document orientation on category tabs and preset cards
Fixes #2084

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 62ab3bd797 Make SVG export of type with control characters well-formed
Found while testing crates/svg/src/export.rs:1908

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 18c1bb0d55 Apply the sign of a picas+points value to the whole value
Found while testing crates/doc/src/lib.rs:~298 (parse_measure, picas+points branch)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 1524ceae09 Evaluate * and / left to right in field arithmetic
Found while testing crates/doc/src/lib.rs:269-297 (parse_measure, used by Unit::parse and parse_number)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
NetterSilverandClaude Haiku 5.5 a59621fa78 Accept a negative operand after an operator in field arithmetic
Found while testing crates/doc/src/lib.rs:272

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:21:27 +07:00
Fabio William ConceiçãoandCursor c53e161e37 fix(G11): Reflect X/Y flip the correct axes (#1910)
Transform Each had Reflect X and Reflect Y swapped relative to Effect › Distort ›
Transform and the Transform panel. Properties flip buttons also used inverted
tooltips. Align them so Horizontal is left-right and Vertical is top-bottom.

Co-authored-by: Cursor <[email protected]>
2026-10-16 02:21:27 +07:00
BFlat 8b22b07e09 Merge pull request #1909 from cecobald/feat/bg-catalog
i18n: add the Bulgarian (bg) catalog
2026-10-16 02:17:32 +07:00
BFlat 8a8b21385b Merge pull request #1761 from euisuh/fix/mcp-protocol-validation
fix(mcp): discard malformed JSON replies before reusing connections
2026-10-16 02:17:08 +07:00
BFlat ccc8a246ae Merge pull request #1886 from marcoarnulfo/fix/text-edit-commit-before-transform
Type: a Transform panel edit or a dialog preview while typing keeps what was typed
2026-10-16 02:16:45 +07:00
BFlat fda8434d93 Merge pull request #1885 from dotCooCoo/feat/distribute-follows-align-to-artboard
Align: Distribute follows Align to Artboard
2026-10-16 02:16:24 +07:00
BFlat 62089aa333 Merge pull request #1923 from marcoarnulfo/fix/move-angle-distance
Move dialog: Distance and Angle, linked to Horizontal and Vertical
2026-10-16 02:15:59 +07:00
BFlat 0a09706453 Merge pull request #2041 from giang17/fix/title-bar-wm-keeps-release
Title bar: a move or resize the window manager finished no longer blocks the next one
2026-10-16 02:15:37 +07:00
BFlat d939e46542 Merge pull request #1472 from LloydNicholson/fix/svg-use-artboards-clip
Place keeps a clipped group's clip
2026-10-16 02:15:17 +07:00
BFlat 47f826b16f Merge pull request #1884 from marcoarnulfo/fix/text-scale-font-size
Info panel: scaled type shows its scaled size, as the Character panel does
2026-10-16 02:14:55 +07:00
BFlat 0ccc501ef9 Merge pull request #1887 from dotCooCoo/fix/issue-1770-pinned-stars
Part of #1770: the pinned Pathfinder test builds its stars from fixed unit vectors
2026-10-16 02:14:32 +07:00
BFlat bdff89b5c1 Merge pull request #2044 from midasmatterhorn/fix/issue-2031-large-canvas-scale
eps: a Large Canvas editing copy is read at the page's scale (#2031)
2026-10-16 02:14:00 +07:00
BFlat d47e5cf461 Merge pull request #2043 from midasmatterhorn/fix/issue-1732-transform-again-after-transform-each
transform: Transform Again repeats a Transform Each (#1732)
2026-10-16 02:13:38 +07:00
BFlat 18186dbeb7 Merge pull request #2042 from midasmatterhorn/fix/issue-2023-transformed-group-images
effects: a Transform effect moves and copies a group's images too (#2023)
2026-10-16 02:13:16 +07:00
BFlat 45922c40ef Merge pull request #2203 from Messhias/fix/2131-clippy-pdf-collapsible-match
fix(pdf): collapse Shading match for clippy 1.95
2026-10-16 02:12:43 +07:00
bflatastic f6b9ab905e i18n(bg): the strings main added since the catalog was written (legacy .ai options, Dimension tool, Revolve cap) 2026-10-16 02:04:36 +07:00
euisuh e9fc3c57ae fix(mcp): discard malformed JSON replies before reusing connections 2026-10-16 02:02:48 +07:00
Marco Arnulfo 256b052981 Type: a Transform panel edit or a dialog preview while typing keeps what was typed (refs #1881)
The Cmd-drag part of #1881 landed in #1897. This covers the two other
paths: an edit from outside the typing session (the Transform panel) and
a new interaction such as a dialog's preview now end the typing first.
2026-10-16 02:02:48 +07:00
dotCooCoo 93742621bc Align: Distribute follows Align to Artboard
With Align to Artboard chosen, the Align panel's six Distribute Objects
buttons and its two Distribute Spacing buttons acted on the selection.
object.distribute and object.distributeSpacing took no to or artboard
param. Distribute Objects kept the outermost objects where they were
and did nothing with two objects, and Distribute Spacing kept the first
object's left or top edge and the last object's right or bottom edge
where they were.

Both commands now take to ("selection", "artboard" or "key") and
artboard, and the panel sends its Align To choice as to. When to is
"artboard", the first object's left or top edge goes to the artboard's
left or top edge, and the last object's right or bottom edge goes to
the artboard's right or bottom edge. Distribute Objects spaces the
other objects evenly by its button's line, and two objects are enough.
Distribute Spacing without a spacing value makes the gaps between the
objects equal. Both commands use the same artboard as object.align.
The app passes the active artboard to both commands, and recorded
actions leave it out, so an action plays on the artboard that is
active when it plays. With "selection" or "key", or without to, both
commands work as before. Distribute Spacing with a spacing value still
keeps the key object in place with each of the three to values. With
anchor points selected, both commands still space the points among
themselves and do not use to.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:02:48 +07:00
bflatastic 8d4a814c81 Move dialog: an angle past 180° reads as its negative one (#1915) 2026-10-16 02:02:48 +07:00
bflatastic f53e7a4c52 i18n(bg): English keeps its own name in the Language menu, as in the other catalogs 2026-10-16 02:02:48 +07:00
cecobald 10b35a4218 i18n(bg): apply terminology (Gradient=Градиент, Bleed=изтичане, Spot Colors=спот цветове) 2026-10-16 02:02:48 +07:00
cecobald ceb69c20d1 i18n(bg): fix typos, artifacts and leaked [@msg] tags (native review) 2026-10-16 02:02:48 +07:00
cecobald 5bcf040659 i18n: add the Bulgarian (bg) catalog
The catalog holds the 4,789 rows the other complete catalogs hold: the whole
interface (every menu string and `tl!` literal) and the status and error
messages, in the Bulgarian vocabulary of vector illustration, following
Adobe Illustrator's Bulgarian conventions; one/other plural forms.

Clean-room from VectorCraft's English meaning, drafted with AI assistance
(Gemini); a native speaker has not reviewed it yet. `bg-BG` and every other
`bg-*` locale resolve here.

`cargo test -p vectorcraft-ui-egui i18n::tests` passes (46 tests, including
complete menu, literal, message and font coverage).
2026-10-16 02:02:48 +07:00
Marco Arnulfo 8e72916a69 Move dialog: Distance and Angle, linked to Horizontal and Vertical (fixes #1915) 2026-10-16 02:02:47 +07:00
Giang Nguyen 07b45e8dd3 contributors: credit giang17 as Giang Nguyen 2026-10-16 02:02:09 +07:00
lloydandClaude Opus 5.5 8d288337f5 Place keeps a clipped group's clip
Place flattened every top-level layer into its objects, so an SVG's clipped top-level group lost
its clip and its clipping path came in as a loose, invisible path; sublayers lost their clips the
same way. A clipping layer is now placed as one clip group, for SVG/DXF and for the art placed from
an artboard (PDF, EPS, placed documents) alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:01:50 +07:00
Marco Arnulfo d0c302c1a3 Info panel: scaled type shows its scaled size, as the Character panel does (part of #1882) 2026-10-16 02:01:50 +07:00
dotCooCoo d425aede37 Part of #1770: the pinned Pathfinder test builds its stars from fixed unit vectors
every_pathfinder_operation_is_pinned built its stars with shapes::star,
which calls the platform's cos and sin. At the star angle
2.204350845268838, cos on macOS x86_64 (measured under Rosetta on
macOS 27.0.1) returns 0xbfe2f1c59f37cbf4, where the correctly rounded
value, which macOS aarch64 returns, is 0xbfe2f1c59f37cbf5. In the set
of five shapes with seed 7, this moves one star vertex by one ulp, and
the set's Unite hashes to 60181664961792917, where PINNED has
9388160212451732406. Under Rosetta, 21 of the 40 hashes differ.

The test now builds each star from a table of the correctly rounded
cosine and sine of the star's ten angles, written as f64 bit patterns,
with the arithmetic of shapes::star. PINNED is unchanged. With these
inputs, all 40 hashes equal PINNED on aarch64 and under Rosetta.
2026-10-16 02:01:50 +07:00
bflatastic 7beebfabb9 eps: apply the Large Canvas scale to EPS files only; a .ai keeps its art space (#2031) 2026-10-16 02:01:50 +07:00
midasmatterhorn 3af1976d19 transform: Transform Again repeats a Transform Each (#1732) 2026-10-16 02:01:50 +07:00
midasmatterhorn d5abc71677 effects: a Transform effect's image copies stay within a node budget (#2042) 2026-10-16 02:01:50 +07:00
Giang Nguyen 6986d87771 Title bar: a move or resize the window manager finished no longer blocks the next one (fixes #2040) 2026-10-16 02:01:50 +07:00
midasmatterhorn 26a9c7a4cc eps: a Large Canvas editing copy is read at the page's scale (#2031) 2026-10-16 02:01:50 +07:00
midasmatterhorn ff79f332a7 effects: a Transform effect moves and copies a group's images too (#2023) 2026-10-16 02:01:50 +07:00
Fabio William ConceiçãoandCursor fc14c38883 fix(pdf): collapse Shading match for clippy 1.95 (#2131)
Rust 1.95 clippy -D warnings rejects the nested if under TypedInstruction::Shading
(collapsible_match). Fold the shading lookup into the match arm guard so the Ubuntu
xtask ci gate can pass on the same toolchain Actions pins.

Co-authored-by: Cursor <[email protected]>
2026-10-16 02:01:49 +07:00
BFlat f5eaf75097 Merge pull request #1879 from RICHRAN2030/feature/image-trace-adjustments
Fix Image Trace adjustment discovery and slider commits
2026-10-16 01:58:20 +07:00
BFlat 21749106d3 Merge pull request #1794 from danngreen/feat/legacy-ai-export
Add Legacy .ai export (keeps groups and layers)
2026-10-16 01:58:03 +07:00
BFlat cc0aec12ae Merge pull request #1788 from osamaamin2011/dimension-tool
Fix #1372: Dimension tool
2026-10-16 01:57:45 +07:00
BFlat a23b2370bc Merge pull request #1740 from gniax/touch-pen-input
Web: Apple Pencil and touch input (palm rejection, pen pressure with coalesced events)
2026-10-16 01:57:27 +07:00
BFlat e18efebf60 Merge pull request #1193 from alpha-rock/codex/revolve-end-caps
Revolve: smooth vector shading and continuous end caps
2026-10-16 01:57:11 +07:00
BFlat ef959c00bd Merge pull request #1403 from vbasky/fix/1326-numeric-drag
Numeric fields: a short drag the toolkit counts as a click keeps its caret (#1326)
2026-10-16 01:56:54 +07:00
BFlat e2d796b555 Merge pull request #2244 from dotCooCoo/fix/pdf-nested-stream-checks
Importers: PDF streams written inside arrays and dictionaries are checked before they are decoded
2026-10-16 01:56:38 +07:00
BFlat 268c2f2703 Merge pull request #2240 from dotCooCoo/fix/pdf-unparseable-color-values
PDF/.ai import: dictionary values the reader can't parse no longer crash the ink and font scans
2026-10-16 01:56:21 +07:00
BFlat c9ead98f1c Merge pull request #2133 from dotCooCoo/fix/pdf-unreadable-object-panics
Importers: a PDF dictionary value the reader can't parse no longer crashes the nesting check
2026-10-16 01:56:04 +07:00
bflatastic baadbe2c67 Image Trace: a quick trace leaves the panel as it was; the Image Trace button opens it 2026-10-16 01:36:25 +07:00
Dan Green ce133351fd Part of #868: legacy .ai writer drops its own depth limit
Documents are now at most vectorcraft_doc::MAX_DEPTH (32) deep, so the writer's MAX_DEPTH of 256
and its warning for art nested deeper can go; symbols and live objects nested in one another keep
their limit.
2026-10-16 01:36:25 +07:00
RICHRAN2030 313b1a52b9 fix(UI.TRACE): expose trace adjustments and reliably apply and undo them 2026-10-16 01:36:25 +07:00
Dan Green 7844f46c65 Part of #868: legacy .ai runs as a PostScript program, with a prolog of its own
The file had an empty prolog, so a PostScript interpreter stopped at its first operator: it
couldn't be printed, placed or previewed by anything that runs PostScript. The prolog's
procedure set (VectorCraft_Legacy, written from the PostScript Language Reference and the
version 7 specification) now gives every operator the writer emits its meaning: paths and their
painting with separate fill and stroke colours and overprints, the fill rule, compound paths
painted as one path, clipping groups and masks, guides that don't print, layers that are hidden
or don't print marking nothing, and images drawn from the hex samples on the comment lines after
XI. Gradients fill with the colour set before them (the first stop's), and the type and encoding
operators take their operands and mark nothing.

VectorCraft opens a file with that prolog from its program, as before: its page has flat
gradients and no type.

Every file the writer's tests write is now run through import_with(bytes, false) and must draw
what the document it was written from draws: its paths, fills, strokes and images in the same
places and colours, with type left out (type the writer outlines is compared as its glyphs'
outlines), gradients as their first stop, layers that don't print left out and hidden objects
shown when the options write them visible. A new test prints the sample with every layer shown.
The engine's flattened-image test also opens the file as PostScript.
2026-10-16 01:36:25 +07:00
Dan Green 06221726fb Part of #868: legacy .ai defines annotatepage in a prolog of its own
The page trailer the version 7 specification gives (section 3, Script Setup) is
`gsave annotatepage grestore showpage`, and annotatepage is a procedure of the app's own
prolog, which the file didn't have. The file now carries a procedure set of the writer's own
(VectorCraft_Legacy 1 0, listed in %%DocumentSuppliedResources) in a dictionary the setup begins
and the trailer ends, which defines annotatepage as marking nothing.
2026-10-16 01:36:25 +07:00
Dan Green 37cebc47ee Part of #868: vectorcraft-cli convert writes legacy .ai (--ai-version 7, --hidden-objects, --transparent-images) 2026-10-16 01:36:25 +07:00
bflatastic 7217dcd121 Dimensions: a file whose dimensions carry options this version doesn't know still opens; commands still refuse unknown options 2026-10-16 01:36:24 +07:00
gniax 6f23190925 fix(web): preserve pen buttons and touch-only input 2026-10-16 01:36:24 +07:00
Dan Green 67e17c1b66 Part of #868: legacy .ai options Hidden Objects and Text, and docs
The Legacy .ai Options dialog gains Hidden Objects (Hidden Layer, Omit, Make Visible) and Text
(Editable, Outlines), translated in every catalog. Docs: type and hidden objects in the legacy
.ai paragraphs, the restored revision rows and roadmap row.
2026-10-16 01:36:24 +07:00
Osama Amin 8aea665c4e Dimension tool: the documented gestures, arcs, picas and text positions; Properties section; review fixes
Drag out from a segment, corner, circle or arc to place a dimension; plain clicks pick two points or two lines. Radial dimensions on perfect arcs, picas, Away/Between and Left/Center/Right text placement. The Properties panel edits a selected dimension, with Apply to All (dimension.applyToAll) and Set as Default. Dimension Selected Paths, the Arc/R/Ø prefixes and the custom defaults are left out. The refresh returns at once in documents without dimensions (Document::dimensions); a linked dimension moved on its own keeps its place; modes, units, line types, arrows and text placement are enums; the tool has its own icon.
2026-10-16 01:36:24 +07:00
gniax c2d832ebc0 docs: pen and touch input on the web in development and hardware parity 2026-10-16 01:36:24 +07:00
Dan Green 6ce1a622be Part of #868: legacy .ai type as text objects and hidden objects kept; legacy type read
Type is written as version 7 text objects (§15): point type, area type and type on a path, with
fonts by PostScript name re-encoded to Windows-1252, and each run's paint, size, scaling,
tracking, leading, alignment and indents; type 8-bit text can't hold (or all, with outlineText)
is outlines. The format hides only layers, so hidden objects go to a hidden layer above their
own (hiddenObjects "layer", the default), are left out ("omit") or show ("visible"). The .ai
reader reads legacy type (To … TO, TE, %AI3_BeginEncoding) and ignores Ar; the import fuzzer
gets legacy type tokens.
2026-10-16 01:36:24 +07:00
Osama Amin 9d813e906a Fix #1372: Dimension tool and Object > Dimensions: linked length, angle, radius and diameter annotations 2026-10-16 01:36:24 +07:00
gniax 9fc7fd7038 feat(canvas): pen strokes keep every position reported between frames
A pen reports far more often than frames come (an Apple Pencil up to 240 times
a second): the canvas now hands the tool each move of the frame with its
pressure instead of the last one only, and the web shell feeds Safari's
coalesced pen events. Mouse input is unchanged.
2026-10-16 01:36:24 +07:00
Dan Green 32df15a554 Part of #868: docs for Save As .ai legacy version 7 (artboard picker, Transparent Images) 2026-10-16 01:36:24 +07:00
Dan Green 91ed4853a8 Part of #868: legacy .ai options dialog, Transparent Images and an artboard picker
The .ai options dialog offers Legacy (version 7). In that mode it shows Transparent Images
(Flatten, Omit) and, with more than one artboard, thumbnails to check with the file each is
saved to (long names keep their end). OK is greyed out until an artboard is checked. A save
panel's Enter no longer confirms the options dialog it opens.
2026-10-16 01:36:24 +07:00
Dan Green bcae7ca486 Part of #868: Save As .ai offers Legacy (version 7), with Transparent Images and an artboard range
aiVersion 7 saves through the legacy writer, one file per artboard; range picks the artboards
and document.save writes them again. Appearances are expanded first. Transparent images
(see-through pixels, opacity or a blending mode) are flattened with the art under them into
opaque images clipped to where they change it (transparentImages "flatten", the default), or
left out ("omit"); both warn.
2026-10-16 01:36:24 +07:00
Dan Green eb320593dc Part of #868: legacy .ai writer to the published version 7 specification
export_legacy_ai writes a document as a PostScript-form .ai of version 7 (one artboard per
file), keeping every layer (name, order, Hidden, Locked) and its groups, compound paths and
clip groups. Closed paths end on their start point, so they keep their corner. The .ai reader
learns what the writer emits, and the tests read every file back.
2026-10-16 01:36:24 +07:00
bflatastic b5b9264d71 Revolve: the Cap option is saved as cap, as Extrude & Bevel and Inflate save it 2026-10-16 01:36:23 +07:00
Vikram Bhaskaran 0c11ff656c Format the reworked select_all_on_focus. 2026-10-16 01:36:23 +07:00
dotCooCoo 433d78284d Importers: PDF streams written inside arrays and dictionaries are checked before they are decoded
A PDF is refused when a stream in it would decode to more than 256 MB
or has decode parameters the reader can't apply, but the check looked
only at the file's objects. A stream written directly inside an array
or a dictionary, such as the /Function of a shading, was not checked.
On main at b12a52f7, a calculator function holding 257 MiB of deflated
spaces opened after 3.0 to 4.3 s with a peak of 550 to 818 MB when it
was written in an array, in a dictionary or as a shading's function.
The same stream as an object of its own was refused in 81 ms.

hostile in pages.rs now runs unsafe_stream on every stream that the
walk of the file's objects meets, at any depth in arrays and
dictionaries, before it decodes the stream as a calculator function.
It refuses such a file with the same message. The walk is the one the
nesting check made before (64 deep at most).
2026-10-16 01:36:23 +07:00
gniax 51673cd098 feat(web): Apple Pencil input on iPad with palm rejection
Read pen and finger Pointer Events in the web shell (capture phase) instead of
eframe's touch handlers, which can't tell a pen from a finger and see every
touch twice. The pen drives the pointer with its pressure and hovers; fingers
are touch contacts for pinch, two-finger pan and undo/redo taps. Once a pen
has been seen (remembered in localStorage), a finger or a resting palm never
presses a tool on the canvas, and a pen landing cancels fingers already down.
A long press drops the tool press and opens the context menu.

The page no longer scrolls, zooms or selects under touch, and the canvas
stays inside the safe areas of a full-screen app.
2026-10-16 01:36:23 +07:00
alpha-rock f703e6bd85 M9: smooth Revolve rendering and default Cap
Use automatic vector smooth shading without a separate control.
Enable Cap by default and keep continuous rim curves and bounded seam joins.
Integrate the shared dialog with the other three 3D effects.
2026-10-16 01:36:23 +07:00
Vikram Bhaskaran 173c88d2a1 Rework #1326: a short drag the toolkit counts as a click keeps its caret.
The late-focus case in the original change has no reproducible path
(#1339 already covers focus on the press frame), so the button-down
defer is dropped. The real issue is the clicked_focus branch: a very
short drag inside one character produces no text selection, egui still
reports a click, and select-all on release replaced the drag. The press
position is now remembered and a release more than three points away
keeps the caret; a plain tap still selects all.

Also fixes the test placement bug: the new test sat between the #[test]
attribute and keyboard_focus_keeps_selecting_the_entire_numeric_field,
so that test no longer ran. The regression test fails on the fork-point
widgets.rs and passes with this change.
2026-10-16 01:36:23 +07:00
Vikram Bhaskaran 8354dbcd74 Keep a numeric field caret when focus arrives while the pointer is still down.
Select-all ran on that late focus frame and replaced the drag the press had started.
2026-10-16 01:36:23 +07:00
bflatastic cf98b7f368 PDF import: one helper for the dictionary values the reader can parse 2026-10-16 01:36:22 +07:00
dotCooCoo 9e90464f20 PDF/.ai import: dictionary values the reader can't parse no longer crash the ink and font scans
The reader's Dict::entries unwraps every value it reads, so it panics on
a value it cannot parse, such as the name /#zz or the number 12-.5
(vendor/hayro-syntax/src/object/dict.rs:104 at craft-libs ca50d63b).
Besides the nesting check in pages.rs, two loops of the import call it:

- Colors::visit (import_color.rs) looks for the file's Separation and
  DeviceN spaces when a page paints in one. craft_pdf's interpret_page
  catches the panic, and the import fails with "PDF page interpretation
  failed internally".
- Walker::note_fonts (import_scan.rs) notes the fonts of each page. The
  import does not catch that panic.

On main the nesting check in pages.rs panics on such a value first
(#2133 fixes it); with it fixed, the import reaches these two loops.

Colors::visit now reads the values with Dict::keys and get_raw, sorted
by key as before, and leaves out a value the reader cannot parse.
note_fonts reads the keys and looks each font up by its key, as before.
2026-10-16 01:34:10 +07:00
dotCooCoo a69ddea930 Importers: a PDF dictionary value the reader can't parse no longer crashes the nesting check
The shared PDF reader (craft_pdf::syntax, craft-libs ca50d63b) indexes
a dictionary by skipping its values. Its skip accepts some values that
its parse refuses, such as a name with a broken # escape (/#zz) and a
number such as 12-.5. Dict::entries unwraps the parse of each value
(vendor/hayro-syntax/src/object/dict.rs:104). The check in pages.rs
that refuses functions and color spaces built on one another without
end (nests_without_end) calls entries on every dictionary of a file
when the file opens. So document.pdfInfo, PDF import,
clipboard.importPdf and loading a .ai panicked on such a file, and
three import fuzz tests fail on main with PROPTEST_RNG_SEED=1182 and
PROPTEST_CASES=20000.

The check now reads a dictionary's values with keys and get_raw and
leaves out a value that doesn't parse.

A test in crates/pdf puts each of those values in a file with a plain
function, which opens, and in a file with a function that stitches
itself, which is still refused.
2026-10-16 01:34:09 +07:00
andeecollardandClaude Opus 5.5 cbe793e059 Image Trace: grayscale gradients use grey stops
In Grayscale mode with Gradients on, the trace could fill paths with
coloured linear gradients (for example a blue-to-orange ramp) beside
grey flat fills. Gradient detection ran on the RGB image, so the stops
and the gradient's palette colour (its middle) were the source colours,
while Grayscale's flat fills come from each pixel's luma.

Grayscale now finds gradients, and settles their edges, in a copy of the
image reduced to the same Rec. 601 luma the flat grays are quantised
from. Every channel of that copy is equal, so the fitted stops, the ends
carried to the area's edges and the palette entry are all grey. Color
mode is unchanged: it still detects on the RGB image (trace output for
gradient fixtures is byte-identical before and after).

Adds grayscale_gradients_are_grey: a code-drawn sky ramp over flat
shapes with a ramp rectangle, traced in Grayscale with 4 grays and
Gradients on, must still produce a gradient, and every path colour,
gradient stop and palette entry must be grey.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-15 18:14:17 +00:00
andeecollardandClaude Opus 5.5 210b98e902 Image Trace: regression tests for shared closed boundaries
Abutting traces must draw each edge between two neighbouring regions once:
the filled paths on either side follow one curve, one of them reversed, and
together cover the image without gaps or overlaps. These tests check that
from the trace output alone, with no renderer: points along each path's
outline must lie within 0.001 px of another path's outline, the paths' areas
must sum to the image's, and every point of a 4 x 4 per-pixel grid must lie
in exactly one path.

The images are drawn in code, hard-edged and anti-aliased, and traced in
Color (Abutting) and Black and White, each at the default settings, Paths and
Corners 0 and 100, Snap Curves To Lines, and Shapes. They cover a closed island
in a background, a ring with and without a core of a third colour, a wavy
two-colour seam, a three-region junction, outlines that leave a junction and
return to it (the 111 / 101 / 110 pattern at 1, 2, 3 and 6 px, and framed),
squares touching at a corner, a one-pixel hole and a one-pixel island (kept at
Noise 1, dropped together with what they leave at Noise 25), separate areas of
one colour, and slanted hatches with one-pixel paper channels. A further test
shows the check finds a 0.01 px shift and stacked (Overlapping) layers.

Two tests are ignored as documented current gaps:
- At Paths 0 and Corners 0, a piece returning to its own junction collapses:
  its region is dropped and its area left uncovered (draft #2205 fixes it).
- With Shapes, a region whose outline runs through a junction becomes an exact
  shape its neighbour does not share (up to 0.0039 px in Black and White).

No tracer code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-15 18:08:58 +00:00
andeecollardandClaude Opus 5.5 56df5969f5 Image Trace: keep shared corners identical at nearby junctions
When a long horizontal or vertical run crosses a three-colour junction one
or two pixels before a corner, the corner correction for that run borrowed
a neighbouring crack's shift that the region on the other side of the
junction never applied, leaving hairline gaps between abutting colours.
The correction now applies only when all three cracks it uses lie in the
same shared piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-15 18:06:48 +00:00
skylar taylor-barrick b12a52f724 PDF.R1: integrate guarded shared PDF writing and reading (#1894)
Integrate the shared PDF writer and reader as one qualified final tree. Preserve the separate writer and reader commits and review records.

All eight local CI gates and all eight hosted jobs passed; the checked source tree matches the final integration.
2026-10-14 22:46:16 -06:00
skylar taylor-barrick 05f6e2e6df PDF.R1: reconcile shared PDF migration with current main
Preserve the writer and reader changes while integrating the current main.

The combined candidate passes all eight local CI gates and the 2,048-case
reader panic verification. Hosted checks remain required before landing.

Refs #1893, #1894.
2026-10-14 22:02:49 -06:00
skylar taylor-barrickandskylartaylor 0946bc5b74 ci: add comfortable runtime limits to unbounded jobs (#2077)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:16:14 +02:00
Brandon ThomasandClaude Opus 5.5 0a33cff3f6 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:05:33 -04:00
skylar taylor-barrick d80950b3f3 CI: isolate the font cache test from parallel font loads 2026-10-14 06:40:06 -06:00
skylar taylor-barrick 664e2cec5f CI: keep command comparison fixtures independent of the clock 2026-10-14 05:10:20 -06:00
skylar taylor-barrick 2ecba1fb9c PDF.R1: reconcile shared reader with current writer 2026-10-14 03:59:03 -06:00
skylar taylor-barrick 52960faf63 PDF.W1: reconcile shared writer with current main 2026-10-14 01:23:19 -06:00
BFlat a067177778 Merge pull request #1223 from dotCooCoo/feat/shortcut-sets
Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
2026-10-14 14:12:14 +07:00
bflatastic d9c9526898 Keyboard Shortcuts: shortcut set reading and matching stay bounded on hostile files
A set whose entries sit in long, deeply nested sections kept every section's names again for each entry (up to hundreds of megabytes from a 4 MiB file); the names kept over all entries now stop at 8 MiB. Name matching compares character pairs in linear time, so a 20,000-entry set of long names is matched in about 2 s instead of 17 s.
2026-10-14 13:49:59 +07:00
dotCooCoo 8c889279f8 Keyboard Shortcuts: importMatches.list answers an unknown parameter key with an error
Since #1442 (3acc29f8), a caller outside the app gets an error for a
parameter key that the command's params description doesn't name, and
every_description_parses requires each top-level entry of the params
object to start with a key. The note on query in importMatches.list had
a comma at the top level ("text in the name, target or label"), so the
description didn't parse: the test failed, and the command took any key.
The note is now in parentheses, and the command takes kind and query.
A test checks that shortcutSet.read and the three importMatches
commands take every key they read and refuse a misspelled one.
2026-10-14 13:22:08 +07:00
dotCooCoo d1108bdbca i18n: Korean, Polish and Vietnamese rows for Import Shortcut Set and its commands
Since #1352, #1498 and #1507, Korean, Polish and Vietnamese are complete
catalogs: every menu string, tl! literal and status or error message has
a row, and the i18n tests check it. ko.tsv, pl.tsv and vi.tsv get the
rows that the other complete catalogs have for Import Shortcut Set and
its commands: the dialog's strings, the labels Import Shortcut Set…,
Read Shortcut Set File, Import Matches, Remember Import Matches and
Forget Import Matches, the two plurals and the two error messages. Copy
Report and Not read are already in all three, and Shortcut and the
Imported message in pl, so they are not repeated. The dialog's rows go
after Import Print Presets in ko and vi and after the Keyboard Shortcuts
dialog's rows in pl, and the two messages after "not a VectorCraft
shortcut set".
2026-10-14 13:22:08 +07:00
dotCooCoo 04244fa2a2 i18n: remove Import Shortcut Set rows that the catalogs already have
Check Script (3374d077) added Copy Report and Not read to every catalog,
and es and fr gained Shortcut and the Imported message in a747daa3 and
9e39a6d1. The rows that Import Shortcut Set added for the same strings
became second entries, which bundled_catalogs_are_consistent rejects.
They are removed, 26 rows in cs, de, es, fr, hu, it, ja, ru, uk, zh-hans
and zh-hant, and the existing rows stay as they are.
2026-10-14 13:22:08 +07:00
dotCooCoo 612a13b39c i18n: Hungarian rows for Import Shortcut Set and its commands
Since b9716366 (#1302), Hungarian is a complete catalog: every menu
string, tl! literal and status or error message has a hu row, and the
i18n tests check it. hu.tsv gets the rows that the other complete
catalogs have for Import Shortcut Set and its commands: 35 strings, the
two plurals in the single form hu.tsv uses (a count takes the
singular), and the two error messages. Shortcut and the Imported
message already have hu rows on main, so they are not repeated. The
rows are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo f1e634af19 i18n: Czech rows for Import Shortcut Set and its commands
Since #1189, Czech is a complete catalog: every menu string, tl! literal
and status or error message has a cs row, and the i18n tests check it.
cs.tsv gets the rows that the other complete catalogs have for Import
Shortcut Set and its commands: 35 strings, the two plurals with one, few
and other forms, and the two error messages. Shortcut and the Imported
message already have cs rows on main, so they are not repeated. The rows
are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo 8c14609370 Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
A shortcut set another app saved as slash-name text (a .kys file whose
text starts with a slash name and "{", such as "/Menus {") opens in
Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts ›
Import… in the desktop app, or from a drop on the window, whether or
not a document is open. The dialog proposes a target for an entry when
an earlier import kept one for that name, or else when one tool,
command or panel matches the name clearly by its id or by one of its
labels. It decodes the file's key and modifier codes from the codes
earlier imports kept, from the example in the AiCommandPalette README
(/Key 48 for the 0 key, with /Modifiers 0 taken as no modifiers), and
from VectorCraft's default shortcuts where at least two entries vote and
four votes in five agree. The user picks the remaining targets, presses
the key for each unknown key code or marks it No equivalent, and chooses
the modifiers for each unknown modifier code. Apply assigns the ready
rows with the Keyboard Shortcuts rules: a tool takes a single key,
optionally with Shift. It goes through the rows from the last one back
and leaves a row out when a later row it applies has the same shortcut
or the same tool, command or panel. Keyboard Shortcuts then shows the
result as unsaved changes until the user clicks OK. Copy Report copies
the set's name, the rows Apply leaves out and the parts of the file that
weren't read.

- shortcutSet.read is a query command: it reads a set and does not
  modify the document or the preferences. It returns each entry with
  its section, codes and line, and up to 1,000 skipped tokens, each cut
  to 80 characters. It reads at most 4 MiB of text and 20,000 entries,
  skips blocks nested more than 16 deep and cuts names at 1,024 bytes.
  With a path it reads at most 4 MiB and 4 bytes of the file, and with
  --automation-read-root (#832) the path must lie inside the read root.
  A file whose text starts with "<", such as an XML shortcut file saved
  as UTF-8, fails with "This shortcut file is in a form VectorCraft
  can't read."
- importMatches.list, importMatches.set and importMatches.reset read
  and change the matches kept with the preferences; resetting the
  preferences keeps them. With Remember My Choices on (the default),
  Apply keeps the targets and codes the user chose, and for the rows it
  assigns, it also keeps the matched targets and the codes decoded by
  agreement. A later import proposes them.
- The name matcher compares a name with ids by exact match and shared
  words. It compares the name with labels, in English and in the
  interface language, by exact match, shared words and character
  pairs. It proposes a match only when one tool, command or panel
  scores at least 0.6 and at least 0.1 more than the next one.
- When Import Shortcut Set is opened from Keyboard Shortcuts, Cancel,
  Escape and ui.dialog.cancel close it and return to Keyboard Shortcuts
  as it was (dialogs::PARENT). A document's queued Missing Fonts dialog
  (#792) opens after Keyboard Shortcuts closes.
- File › Open has a "Shortcut sets" filter, and in the desktop app
  Keyboard Shortcuts' Import… shows .json and .kys files.

Docs: docs/mcp.md, docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md, docs/roadmap.md.
2026-10-14 13:22:07 +07:00
BFlat b076025a31 Merge pull request #1897 from storytold/fix/1881-typing-cmd-drag
Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
2026-10-14 12:38:12 +07:00
BFlat 3bb87989df Merge pull request #1896 from storytold/fix/pattern-tiles-brush-cms
Pattern tiles after brush edits and Color Settings; MCP docs on failed batches
2026-10-14 12:38:00 +07:00
BFlat a40e8f5aff Merge pull request #1895 from storytold/fix/1866-section-highlight
Fix #1866: Preferences and Save PDF highlight the section shown
2026-10-14 12:37:48 +07:00
BFlat 1a8e0456cb Merge pull request #1880 from marcoarnulfo/fix/tab-leader
Text: draw tab leaders in the tab's gap
2026-10-14 12:37:27 +07:00
bflatastic 7f1ad31a7b Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
With the Type tool editing, a press with Ctrl (Cmd on the Mac) lends the
Selection tool for the drag. Its drag joined the open typing session, so its
preview started from the document before the typing: the type moved or
scaled and lost what had just been typed. The typing is now kept as its own
undo step before the lent tool takes the press, as Undo already does, and
typing goes on after the drag.
2026-10-14 12:27:04 +07:00
bflatastic 6fd31d4dbd MCP docs: a failed batch leaves the files its steps wrote
The rollback of a failed command.batch (#1862) puts the documents back, not
the disk. Say so, and how to keep a batch from writing unless it succeeds.
2026-10-14 12:27:04 +07:00
bflatastic f9b051d3d1 Fix #1866: Preferences and Save PDF highlight the section shown
The section lists asked for a selectable button and then turned its frame
off while the pointer was elsewhere, which also dropped the selected row's
fill: the section shown looked like every other row. The selected row now
keeps its fill.
2026-10-14 12:27:04 +07:00
bflatastic 6bc0222074 #1723 follow-up: pattern tiles are drawn again after a brush edit or new Color Settings
A tile was kept while the pattern's art and what it shows were unchanged,
but a brush the art is stroked with lives in the document's other data, and
every tile is drawn through the color settings. A tile whose art reads more
than itself now also remembers the document's other data, and every tile
remembers the color settings it was drawn through (both weakly).
2026-10-14 12:27:04 +07:00
Marco Arnulfo eff2ccdc34 Text: draw tab leaders in the tab's gap (fixes #1877) 2026-10-14 12:27:03 +07:00
BFlat c73871eacc Merge pull request #1891 from storytold/fix/1691-shape-builder-gaps
Shape Builder: the gaps closed paths enclose are regions (#1691)
2026-10-14 12:14:45 +07:00
BFlat 2afd2654e8 Merge pull request #1859 from dotCooCoo/feat/proof-preview-prepared-document
Render: the art prepared for Separations Preview and Overprint Preview is kept between frames
2026-10-14 12:14:20 +07:00
BFlat 725c3881d4 Merge pull request #1860 from dotCooCoo/feat/web-build-clock
Web: the status bar shows measured render and UI times instead of 0.0 ms
2026-10-14 12:14:03 +07:00
BFlat aa93258ea7 Merge pull request #1861 from dotCooCoo/fix/hidden-chars-release-when-off
Type: Show Hidden Characters keeps no text nodes while it is off or no document is open
2026-10-14 12:13:42 +07:00
BFlat fe735f0e7d Merge pull request #1890 from storytold/docs/progress-1014b
Docs: ROADMAP and parity docs for the later 2026-10-14 merges (#1630–#1875); gaps noted for reporters
2026-10-14 12:13:08 +07:00
bflatastic 65e2168791 Shape Builder: the gaps closed paths enclose are regions (#1691) 2026-10-14 11:28:21 +07:00
dotCooCoo c44ece3113 Render: the art prepared for Separations Preview and Overprint Preview is kept between frames
With Separations Preview on, the renderer copied every node of the
document in every frame and separated each of its colors again. With
Overprint Preview on, it copied each object with an overprinting fill
or stroke in every frame, with the groups and layers that hold it. The
copies were new Arcs, so the geometry, stroke outline and raster
caches missed for each of them in every frame. In a release build,
drawing a 2880x1800 frame of a 50,000-path document again with the
same view and Separations Preview took 795 to 908 ms.

The renderer now keeps the art it prepared and what it was made from,
and in the next frame it uses that art again for each layer, symbol
art and member that has not changed. Only the objects that changed are
prepared again, with the layers and groups that hold them. All of the
art is prepared again when Discard White Overprint changes and, with
Separations Preview, when the plates shown, the proof setup, the
swatches, the Spot Colors option or the color settings change or a
profile is registered (cms::profiles_generation). The renderer now
draws that frame again in 98 to 107 ms, with the same pixels.

Docs: docs/target-app-parity.md.
2026-10-14 11:22:41 +07:00
dotCooCoo 014021577b Web: the status bar shows measured render and UI times instead of 0.0 ms
In the web build, now_ms() in crates/ui-egui/src/lib.rs returned 0.0.
The status bar read "render 0.0 ms · ui 0.0 ms" however long a frame
took, and the Layers panel's budget of 8 ms for the row thumbnails it
renders in a frame never ran out, so only its limit of 16 thumbnails
applied.

now_ms() now returns the milliseconds since its first call, read from
the browser's performance.now() through web-time, which the build
already has through eframe. The desktop clock is unchanged. In Chrome,
after a pan frame of 750 ms over a document of 50,000 paths, the status
bar now reads "render 750.7 ms". The Layers panel now renders the
thumbnails of 24 groups of 2,000 paths one per frame, where main
rendered nine of them in one frame of 217 ms. The canvas has no render
worker in the web build, so it still renders on the UI thread and draws
the same pixels.

Docs: docs/target-app-parity.md.
2026-10-14 11:22:41 +07:00
dotCooCoo abe89d5176 Type: Show Hidden Characters keeps no text nodes while it is off or no document is open
Show Hidden Characters keeps each text object's marks together with
the node it laid them out from. Only a frame that drew the overlay
changed what it kept. After the option was turned off, or the
document was closed, those nodes stayed alive until the overlay was
next drawn.

The canvas now takes the kept marks at the start of each frame and
puts them back only when it draws the overlay. A frame that does not
draw it, for example with the option off or with no document open,
drops the marks and their nodes. A frame that draws the overlay still
keeps the marks of the frame before.
2026-10-14 11:22:41 +07:00
bflatastic 0c0d2448da Docs: ROADMAP and parity docs for the later 2026-10-14 merges (#1630–#1875); gaps noted for reporters; macOS save sheet Format menu back to missing 2026-10-14 11:22:40 +07:00
skylar taylor-barrick e01a65d8a9 PDF.R1: integrate shared reader with bounded nesting checks 2026-10-13 21:58:01 -06:00
BFlat 5a120e323b Merge pull request #1875 from storytold/xtask/web-wasm-check
xtask: the wasm check compiles the web app; the web's SVG depth cap follows from its stack
2026-10-14 10:29:29 +07:00
BFlat 5de774835b Merge pull request #1874 from storytold/fix/krilla-no-simple-text
PDF: krilla without its text shaping, so rustybuzz and ttf-parser don't ship (#1730)
2026-10-14 10:29:19 +07:00
BFlat 1a280e1402 Merge pull request #1873 from storytold/fix/pdf-hostile-streams
Importers: PDF streams the reader would fail on are refused (#1700, #1701)
2026-10-14 10:29:09 +07:00
BFlat 8bd8e2b298 Merge pull request #1872 from storytold/fix/brushes-drawn-by-name
Importers: a brush that draws itself by name no longer crashes
2026-10-14 10:28:57 +07:00
BFlat cd1b590aa8 Merge pull request #1871 from storytold/fix/pdf-nested-containers
Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8)
2026-10-14 10:28:48 +07:00
BFlat dcd0da87cb Merge pull request #1870 from storytold/fix/svg-link-scope
Importers: an SVG's linked files are read within the automation roots again
2026-10-14 10:28:37 +07:00
bflatastic f774968b20 xtask: the wasm check compiles the web app; the web's SVG depth cap follows from its stack
`cargo xtask wasm` (part of `cargo xtask ci`) checked the wasm-safe crates but not
apps/vectorcraft-web, so web-only code was never compiled before a merge. It now runs
`cargo check --target wasm32-unknown-unknown -p vectorcraft-web` too (no Trunk, no network).

The SVG importer's cap on the web was 48, sized from native measurements. rustc links the web
build with a 1 MiB stack (`-z stack-size=1048576`; the app passes no linker arguments of its
own) and usvg uses about 15 kB a level: half the stack at that cost gives 34, set in one place
with the arithmetic.
2026-10-14 10:13:45 +07:00
bflatastic f1fd8e85e8 PDF: krilla without its text shaping, so rustybuzz and ttf-parser don't ship (#1730)
krilla's default `simple-text` feature is the only thing that brings in rustybuzz and
ttf-parser, which RustSec lists as unmaintained. The PDF writer places glyphs itself, so the
workspace takes krilla with `raster-images` only; the one test that writes files with
`draw_text` turns `simple-text` on as a dev-dependency feature.
2026-10-14 10:13:45 +07:00
bflatastic 991ce2c635 Importers: PDF streams the reader would fail on are refused (#1700, #1701)
hayro (0.7.2 and 0.8.0 alike) inflates Flate and LZW data with no ceiling, so a few megabytes
of zeros ask for gigabytes, and applies a predictor before checking its parameters: Columns 0
divides by zero (a panic, an abort on the web), a huge Columns allocates a row that large (an
abort everywhere). Both happen inside the reader, before any of our limits.

A file is refused ("a stream in it would decode to more than 256 MB, or its decode parameters
can't be applied") when a stream's decode parameters ask for a predictor the reader can't apply,
or when it decodes past 256 MB through Flate, LZW, run-length and the ASCII encodings before
them, decoded here with a ceiling (only streams large enough to get there are decoded). Every
object's stream is looked at once the file is open (decrypted, references followed), with the
inline images of anything that can be content; object and cross-reference streams, which the
reader decodes while it opens the file, are looked at first in the file's text.
2026-10-14 10:13:45 +07:00
bflatastic 0efcaeb558 Importers: a brush that draws itself by name no longer crashes
A scatter brush whose art is an instance of a symbol stroked with that brush, or any brush whose
art is painted with a pattern whose tile is stroked with it, drew itself without end: a stack
overflow in a hand-written native file. Brush pieces already strip the brushes of their own
strokes; now what they draw by name that would draw a brushed stroke (a symbol instance, or a
pattern paint, also through the symbols and patterns those use) is left out too, in one place
(`stroke_pieces`, which every renderer and exporter calls with the document).

Probed the rest of item 2 of the depth follow-up with hand-written hostile inputs, kept as tests:
graph designs of deep art and of a symbol holding itself, Affinity groups nested to and past
both of its reader's caps, DXF blocks inserting themselves, EMF/WMF saving states without end,
and plug-in manifests and output nested 30,000 to 100,000 deep. None of them overflowed.
2026-10-14 10:13:45 +07:00
bflatastic 17d2493616 Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8)
hayro-syntax 0.8.0 reads arrays and dictionaries 64 deep and no deeper wherever they are (the
file's text, object streams, the content of pages, forms, patterns, glyphs and masks), walks the
page tree without recursion and follows a chain of object streams once: what 0.7.2 overflowed
the stack on. hayro-interpret 0.8.0 comes with it (a new device interface: the importer's device
takes it as before). Our raw pre-scan of the file's text and page content goes: the reader's own
cap covers more, and the scan miscounted the bytes of unfiltered binary streams.

What 0.8.0 still builds by recursion with no cap is refused when the file opens: functions and
colour spaces built on themselves or on one another more than 16 deep, and calculator functions
whose procedures nest more than 64 deep. The import runs on a 16 MB thread, as the SVG importer
does.

hayro-syntax 0.8 turns on flate2's zlib-rs backend, which the whole app then deflates with: a
compressed native file is the same gzip stream as before (header and data), but its deflate
blocks now depend on how the data is fed, so the streaming test checks the header and the
unpacked data instead of the bytes.
2026-10-14 10:13:45 +07:00
bflatastic 3ae71c16ae Importers: an SVG's linked files are read within the automation roots again
The SVG importer runs on a thread of its own since #1852 (for its stack), and the automation
roots in force on the thread that opened the file didn't hold there: an agent confined to a
read root could have the app read any file an SVG links to. The reads now run in the scope of
the thread that asked.
2026-10-14 10:13:45 +07:00
BFlat ecf86ca21e Merge pull request #1865 from storytold/fix/1685-save-failure-dialog
Fix #1693, #1694, #1685: a failed save is reported in a dialog that stays; failed downloads; opens under a dialog
2026-10-14 09:51:48 +07:00
BFlat 886b33c584 Merge pull request #1864 from storytold/fix/audit-render-effects
Fix #1706, #1705, #1724, #1723, #1683: shadow raster budget, Transform copies, 3D fill rule, pattern tiles, template Dim Images
2026-10-14 09:51:34 +07:00
BFlat 2af6f5bfbe Merge pull request #1863 from storytold/fix/audit-importers
Fix #1715, #1714, #1704, #1699, #1695: bounded ASCII85 and unpacking, EPS cycles, Affinity pixel allowance, save fallback
2026-10-14 09:51:24 +07:00
BFlat cde9ee8a71 Merge pull request #1862 from storytold/fix/audit-ids-batch-control
Fix #1718, #1697, #1725, #1717: id exhaustion, batch rollback, timed-out control requests, RGBA size before decoding
2026-10-14 09:51:14 +07:00
skylar taylor-barrick a699a26221 refactor(pdf): use shared writer for export and print (PDF.W1) 2026-10-13 20:41:14 -06:00
bflatastic e802ea8438 Fix #1685: a save or export that fails is reported in a dialog that stays
A save that couldn't write its file (a read-only folder, a file another
program holds open, a full disk) was only reported in the status bar, a line
the next message replaces; a background save's failure arrived there some
time after the user had moved on. Save, Save As, Save a Copy, Save as
Template, Export As and the commands that write one file now also say it in a
dialog naming the file and the reason, which stays until it is closed. The
document stays modified and keeps the file it had. A message that arrives
while another dialog is open waits for it to close instead of replacing it.
An agent's request gets the error as its reply, without a dialog; a failed
recovery write stays in the status bar.
2026-10-14 09:03:51 +07:00
bflatastic 8f8d49110f Fix #1694: a file that finishes opening waits while a dialog is open
A document opened off the UI thread became the active one as soon as it was
ready, also under an open dialog, whose OK acts on the active document: Save
As options confirmed after another file had finished opening wrote that
file's art under the first document's name. Files that are ready now wait
until the dialog is closed.
2026-10-14 09:03:32 +07:00
bflatastic 5ecd045156 Fix #1693: a browser download that can't be started fails the save it is for
The web app's download callback returned nothing, so a save whose Blob, URL or
anchor couldn't be made was reported as written: the document counted as
saved, its recovery copy was dropped, and Save Changes closed it. The callback
now returns the error, which fails the save, export or file command like a
refused write on the desktop. A timer that can't be set after the download
started is not a failure.
2026-10-14 09:03:31 +07:00
bflatastic 5688294d56 Fix #1683: a template layer dims its images by Dim Images, not the whole layer
The canvas faded a template layer as a whole by a fixed half and ignored its
Dim Images option, so unchecking it or setting another percentage changed
nothing, and the layer's paths were faded with its images. A template layer's
images are now dimmed to its Dim Images percentage (50 % when Layer Options
makes it a template), shown at full strength with the option off, and its
vector art is drawn as it is.
2026-10-14 09:02:28 +07:00
bflatastic c348b384aa Fix #1723: a pattern's tile is drawn again when what its art shows changes
A tile was kept while the pattern's own art, tile and tiling were the ones it
was drawn from. Art that shows a symbol, is painted with another pattern or
holds an image or type doesn't change when those do, so the pattern kept
drawing the old symbol (in every renderer on the thread: the tiles are
shared). Such a tile now also remembers the document's pattern and symbol
art, image pixels and fonts, and is drawn again when they differ. A raster
effect on an object painted with a pattern reads all of that too, as one on
a symbol instance does.
2026-10-14 09:02:28 +07:00
bflatastic a6d5c461a1 Fix #1724: 3D effects on a nonzero compound path keep the area its contours fill
Extrude & Bevel, Inflate and Rotate built their solid from the outline by
nesting alone: a contour inside an odd number of others was a hole. Under the
nonzero rule a contour inside another that runs the same way encloses filled
art, so the effect cut a hole the object doesn't have. The fill rule now
reaches the outline builder, which leaves out contours with filled art on
both sides before pairing outlines and holes.
2026-10-14 09:02:28 +07:00
bflatastic 779120cb5b Fix #1705: a Transform effect's copies are bounded by the anchors they make
Each Transform effect clamps its own copies to 1000, but each copies all of
what the effect before it made: three of them on one square ask for a
thousand million squares, from a small file. Copies now stop where the result
would pass two million anchors (1000 copies of a 2000-anchor path).
2026-10-14 09:02:28 +07:00
bflatastic 2e6b925793 Fix #1695: the copy-over fallback of a save keeps the old file until the new one is in
Where a folder refuses the temporary file or the rename, the new content
(whole and flushed elsewhere) is copied into the file itself: the file was
truncated first, so a copy cut short by a lost connection or a crash left
neither version in place, and only the new one on disk under a hidden name.
The file as it was is now copied and flushed beside the temporary file before
it is truncated; if it can't be, the file is left alone and the save fails,
naming the temporary file. A failed copy names both.
2026-10-14 09:00:42 +07:00
bflatastic 48041c8cd3 Fix #1706: a shadow or glow too large to cache is filtered within the pixel budget
The fallback for a drop shadow, outer glow or inner glow whose raster the
shadow cache refuses rendered the view plus the blur's spread at full
resolution, capped only at 65535 pixels a side: a 1000 pt blur at 3200 %
spreads 48000 pixels, so the surface was up to 16 GB whatever the window's
size. The region is now planned by filter_region, which renders it at a lower
resolution (as filtered content already is) to stay within PIXEL_BUDGET.
2026-10-14 09:00:42 +07:00
bflatastic 1d282159dd Fix #1699: opening a streamed native file unpacks a bounded number of bytes
The limits on a compressed file's entries were on their lengths. An entry of
one byte at a far offset unpacked everything before it, and each entry behind
the previous one started the stream over and unpacked the JSON and the gap
again: a small hand-made file could ask for hours of decompression. All of it
is now counted against one budget (the entries a writer can make, and the
JSON once more), past which the file is refused as too big.
2026-10-14 09:00:42 +07:00
bflatastic 0ad92e4649 Fix #1704: an Affinity document's pixel layers share one pixel allowance
Each pixel layer was held to 64 megapixels, and the archive's limits count
the bytes extracted from it; a layer whose tiles are flat colour needs none,
so a small file with many of them allocated 256 MB each without end. The
layers of one document now decode 512 megapixels (2 GB) at most together;
those past it are left out with a note, as an oversized one is.
2026-10-14 09:00:42 +07:00
bflatastic 729e5ced7f Fix #1714: the EPS interpreter frees arrays and dictionaries that hold themselves
Composite objects are reference-counted, so `/a 1 array def a 0 a put` (or two
dictionaries naming each other) outlived the import: emptying the standard
dictionaries and the stacks doesn't break a cycle, least of all one the
program no longer reaches. Up to the interpreter's whole memory budget could
stay allocated per imported file. The interpreter now lists the arrays and
dictionaries it makes (weakly, swept as they are dropped) and empties the
ones still alive when it is done.
2026-10-14 09:00:42 +07:00
bflatastic 0cb55bc0aa Fix #1717: sampling and recolouring check an image's RGBA size before decoding
The decoder's 512 MB limit is on the file's samples; a grey image within it
is four times that once converted to RGBA for the Eyedropper or a recolour,
which the web build can't allocate. The size is now read from the header and
held to the allowance the importers and the renderer use.
2026-10-14 08:47:10 +07:00
bflatastic 277e893e82 Fix #1715: ASCII85 decoding is bounded while it decodes
A `z` is four bytes for one character, so the text's length didn't bound
the result, and the filter's and the interpreter's budgets only saw it once
it was whole. The decoder now stops at 256 MB, the most one filter or data
section may hold.
2026-10-14 08:47:10 +07:00
bflatastic ea30e411e7 Fix #1725: a control request its caller timed out on is withdrawn, not run later
The worker answered "timeout" after 60 s and left the request on the queue, so
the app ran it whenever it next drained the queue: an edit nobody was waiting
for, and a second one if the caller had retried. A request now leaves the
queue once: taken by the app, or given up by its caller, whichever comes
first. The timeout reply says which ("will not run" / "still running").
2026-10-14 08:47:10 +07:00
bflatastic 355bb5c4f6 Fix #1697: a failed command.batch restores each document whole
The rollback undid the batch's interaction, which holds the art, the selection
and panel state only: a Save As step's new file, format and saved state stayed,
and an Undo step (which ends the interaction) left the art and the history
undone. The batch now keeps the documents as they were and puts them back,
and settles a drag or typing in progress first, as Undo does, instead of
running inside it and keeping its baseline as the batch's step. Files the
steps wrote stay written.
2026-10-14 08:47:10 +07:00
bflatastic 7aa00b41da Fix #1718: ids at their largest value in a file no longer exhaust the counters
A native file (or a pasted document) whose node, slice or asset ids or whose
next-id counter sit at u64::MAX made the next allocation overflow: a panic
with overflow checks, a wrapped counter handing out ids already in use
without. fix_next_id now leaves ids and counters from 2^62 up out of the
count, so new ids start under them; alloc_id saturates. Artboard and graphic
style ids take the smallest free id when the largest is u32::MAX instead of
wrapping to the reserved 0.
2026-10-14 08:47:10 +07:00
BFlat f3a3224745 Merge pull request #1839 from marcoarnulfo/fix/transform-panel-anchors
Transform panel: X/Y/W/H edit the anchors selected with Direct Selection
2026-10-14 08:38:19 +07:00
BFlat e6bf586556 Merge pull request #1840 from mjohnson7cd/fix/1816-snap-to-grid
fix(snap): snap Direct Selection anchor drags to grid intersections (#1816)
2026-10-14 08:38:03 +07:00
BFlat 263d2f23c8 Merge pull request #1850 from dotCooCoo/chore/docs-perf-and-gaps-facts
Docs: G15 and the parity doc cite budget runs and slowness reports since 2026-10-11
2026-10-14 08:37:41 +07:00
bflatastic 61ef472e71 object.setBounds: one helper for the asked width and height 2026-10-14 08:17:14 +07:00
Marco Arnulfo 4ef0cb76e6 Transform panel: X/Y/W/H edit the anchors selected with Direct Selection (fixes #1824) 2026-10-14 08:16:26 +07:00
Mara Johnson 891a58eff8 fix(snap): snap Direct Selection anchor drags to grid intersections (#1816) 2026-10-14 08:16:24 +07:00
dotCooCoo 318e6b07b7 Docs: G15 and the parity doc cite budget runs and slowness reports since 2026-10-11
The "Performance unverified" bullet in docs/target-app-parity.md said
that the budgets had not been run on an idle machine since 2026-10-01,
that the last loaded run missed the 16 ms frame budget at 50k paths,
and that no user had reported slowness in mainstream work beyond #575
and #758. On 2026-10-11 #1155 recorded idle-machine runs, made before
#1173 merged, and runs of #1173's change met every budget at 50k paths
(load average 4 to 7) and at 25k paths. #1097 (zoom and pan in 0.9.0)
and #1731 (a drag that stutters as it starts) are open. G15 in
docs/gaps.md called the 25k-path run the last one, but on 2026-10-13 a
comment on #1172 reported runs of 0.10.0 on Linux with 4 logical CPUs
that missed all three render budgets at 20k paths.

The bullet now gives the 25k-path run and the runs in #1172, and lists
#1097 and #1731 with #575 and #758 as open user reports of slowness.
G15 no longer calls the 25k-path run the last one and adds the runs in
#1172.
2026-10-14 08:16:23 +07:00
BFlat ba5447bbd9 Merge pull request #1855 from storytold/fix/1800-mac-save-sheet
Fix #1800: macOS Save As shows its sheet again
2026-10-14 08:12:38 +07:00
BFlat c3a830bc38 Merge pull request #1857 from storytold/fix/1747-prefs-on-ok
Fix #1747: Preferences are written when confirmed, not only at exit
2026-10-14 08:12:29 +07:00
BFlat b56400b5f9 Merge pull request #1856 from storytold/fix/1719-redo-interaction
Fix #1719: Redo keeps a drag in progress as a step, as Undo does
2026-10-14 08:12:19 +07:00
bflatastic 494ce39de5 Fix #1747: Preferences are written when confirmed, not only at exit
The preferences file was written only when the app quit normally, so settings
confirmed with OK were lost when the session ended any other way (the system
shutting down, the process being ended, a crash). OK now asks the host to
write the file at once.
2026-10-14 08:11:06 +07:00
bflatastic f97d57f859 Fix #1719: Redo keeps a drag in progress as a step, as Undo does
Redo swapped the document and the history under a live interaction, whose
snapshot was still the document from before: the next preview or a cancel
brought that one back while the history recorded the redo, so the redone edit
was lost. Redo now settles the interaction first, the way Undo already did: a
drag that changed something is kept as the newest step (which ends the steps
to redo), one that changed nothing is dropped.
2026-10-14 08:11:02 +07:00
bflatastic a60f7e1858 Fix #1800: macOS Save As shows its sheet again
The Format menu added to the save sheet after rfd began it (#866) made recent
macOS versions (26.6.2 and 27.0.1 reported) dismiss the sheet at once: Save As,
Save a Copy and Save of an untitled document flashed and answered as cancelled,
while panels without it (Open, Export As) worked. The accessory is withdrawn
until it can be set before the sheet is presented; the format still follows
the extension typed in the name field, as before #866.
2026-10-14 08:10:58 +07:00
BFlat 8fd0612069 Merge pull request #1836 from Muratv07/feature/marquee-select-guides
feat: marquee selection selects unlocked ruler guides
2026-10-14 08:10:45 +07:00
BFlat 3f5838843d Merge pull request #1833 from switch87/fix/1738-symbol-text-bounds
Native reopen: type in symbols gets its layout bounds back (#1738)
2026-10-14 08:10:33 +07:00
BFlat 1d62d75150 Merge pull request #1830 from switch87/fix/1735-mask-global-swatch
Global swatch edits reach opacity-mask art (#1735)
2026-10-14 08:10:15 +07:00
BFlat 7e3fc2d021 Merge pull request #1834 from saadkhantareen/fix/1703-svg-selector-backtracking
fix(svg): memoize descendant selector matching to stop combinatorial backtracking (#1703)
2026-10-14 08:09:53 +07:00
BFlat b45ceb9427 Merge pull request #1851 from storytold/docs/image-trace-1014
Docs: Image Trace Shapes rework, stroke caps, enhanced presets, gradients over noise
2026-10-14 08:02:09 +07:00
bflatastic ed96b2ff65 Marquee guide selection: one shared helper for both selection tools 2026-10-14 08:01:50 +07:00
Gert Pellin e54fe9c31e Native reopen: type in symbols gets its layout bounds back (#1738)
Type's layout bounds are a cache that isn't saved. On open they were
computed again only for type in the layers, so type in a symbol definition
(or a pattern tile, graph design or opacity mask) kept the rough estimate,
and an envelope warping it was fitted to a different box: warped type in a
symbol changed shape after a save and reopen. The refresh now goes through
every art tree, masks included.
2026-10-14 08:01:50 +07:00
Gert Pellin 904414aa7d Global swatch edits reach opacity-mask art (#1735)
A mask's art lives outside the layer tree (in the masked object's extras),
and the walks over linked colours skipped it: editing a global swatch
recoloured everything but the mask, which kept showing (or hiding) as
before, and a swatch used only in a mask counted as unused. The colour
walks now go into opacity masks: map_solid_paints (swatch edits, renames,
deletes, Merge and the like) and visit_paints (unused swatches).
2026-10-14 08:01:50 +07:00
Saad Abdullah ad165a5a00 fix(svg): memoize descendant selector matching to stop combinatorial backtracking (#1703) 2026-10-14 08:01:50 +07:00
Murat Vural 8796aa9d85 feat: marquee selection selects unlocked ruler guides 2026-10-14 08:01:50 +07:00
BFlat 225f3d7b0c Merge pull request #1751 from AyberkA0/feat/toolbar-drawer-groups
Fix #1736: toolbar without group labels, and an All Tools drawer with flyout groups, shortcuts, menu and Show section
2026-10-14 07:58:09 +07:00
BFlat 0ae6c288ea Merge pull request #1853 from storytold/fix/1629-actions-selection
Fix #1629: an action clicked in the Actions panel stays selected, so Play and Delete act on it
2026-10-14 07:57:26 +07:00
BFlat b25c106fbf Merge pull request #1852 from storytold/fix/deep-nesting
Importers: nesting is capped, so a deeply nested file can't overflow the stack
2026-10-14 07:57:01 +07:00
bflatastic 22118a3e8e Edit Toolbar: the drawer's groups have a named type (clippy's type-complexity) 2026-10-14 07:37:54 +07:00
Ayberk A. 8894da68d7 Part of #1736: one toolbar column runs on without group labels too
Without group labels, the single column no longer draws a dash between
the groups: its tools are evenly spaced from top to bottom, as two
columns already run on from group to group. With the labels, each group
still starts under its name.
2026-10-14 07:37:54 +07:00
Ayberk A. cdd656bd48 Part of #1736: two toolbar columns run on without group labels, and the drawer stays under the title bar
- Without group labels, the double-column toolbar fills every row: a
  group no longer starts a half-empty row, as the reference's Basic
  toolbar runs on in two columns. One column keeps its faint dashes, and
  with the labels each group still starts its own row under its name.
- The All Tools drawer, now a tall single column, never goes above the
  toolbar's top: on macOS it ran under the window's buttons. Its scroll
  height is what is left from there to the window's bottom.
2026-10-14 07:37:54 +07:00
Ayberk A. 03c0b8f355 Part of #1736: the toolbar without group labels, and the All Tools drawer's groups, flyout menu and Show section
- Preferences › User Interface › Show Tool Group Labels is off as it
  ships: the toolbar sets its groups apart with a faint dash, as the
  Basic and Advanced toolbars of the public toolbar reference do (the
  categories there are the Getting Started toolbar's). It no longer cuts
  "Navigate" to "Navi...". The labels are a preference away.
- The All Tools drawer lists the tools one per row (icon, full name,
  shortcut on the right), each flyout group framed together under the
  heading most of its tools have (the Pencil's group, led by the Shaper,
  under Draw). Tools already in the toolbar stay dimmed; dragging and
  picking are as before.
- Its ☰ menu has Reset and the Basic and Advanced toolbars, the drawer's
  flyout menu of the reference.
- Its Show section shows or hides the controls under the toolbar's tools
  (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the reference
  describes; `toolbar.show {control?, on?}`, saved with the preferences
  (`ui.toolbar_show`, all on for older ones). Edit Toolbar's ••• stays.
- New strings in every catalog.

Reference: https://helpx.adobe.com/illustrator/using/tools.html (the look
is VectorCraft's own, with its own icons).
2026-10-14 07:37:54 +07:00
bflatastic 0934f97627 Fix #1629: an action clicked in the Actions panel stays selected, so Play and Delete act on it 2026-10-14 07:37:53 +07:00
bflatastic c10da25ce4 Importers: nesting is capped, so a deeply nested file can't overflow the stack 2026-10-14 07:37:52 +07:00
bflatastic 9c91c36e7e Docs: Image Trace Shapes rework, stroke caps, enhanced presets and gradients over noise (#1845 to #1848) 2026-10-14 07:19:49 +07:00
BFlat 8ab574b7de Merge pull request #1848 from storytold/u/trace-gradients2
Image Trace: gradients grown over noise and soft rims
2026-10-14 07:18:39 +07:00
bflatastic e0ba6e77d1 Image Trace: gradients grown over noise and soft rims
Gradients (off unless asked for) found a blurred or noisy linear gradient in
part or in pieces, with bands around it.

The areas that may be a gradient are tried largest first, and one that is a
gradient is grown over every pixel next to it whose colour is within 10 levels
of the gradient's there: the pixels noise put a little off, a soft rim, the
pieces noise cut off it.

Linear-gradient inputs with Gradients on, paths beside the gradient, before ->
after: blurred 3.0 -> 0.5 (6 Colors) and 55.5 -> 0.5 (High Fidelity Photo);
noisy 11.0 -> 0.5 and 23.5 -> 1.5; scanned 5.5 -> 3.0 and 46.5 -> 37.0; clean
and aliased as before; dE within 0.02. No gradient on any input that is flat
colour, with any preset (the harness fails if there is one).
2026-10-14 07:14:07 +07:00
BFlat 9c289db91d Merge pull request #1847 from storytold/u/trace-enhanced
Image Trace: enhanced versions of the presets
2026-10-14 07:08:27 +07:00
BFlat b1a5b79d67 Merge pull request #1846 from storytold/u/trace-caps
Image Trace: stroked lines get square caps where their ink is cut square
2026-10-14 07:07:45 +07:00
BFlat 156fbe9912 Merge pull request #1845 from storytold/u/trace-shapes2
Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
2026-10-14 07:07:15 +07:00
bflatastic 1f4daf519d Image Trace: enhanced versions of the presets
Each documented preset has an enhanced version (`enhanced: true` in
`imageTrace.make`, an Enhanced checkbox under the panel's preset): the preset
with Auto Grouping, and with Shapes in all but High Fidelity Photo. Gradients
stay an option. Flat Logo, a preset of our own, has none. The legacy presets,
and documents traced with them, are as they were.

So that an enhanced preset is no worse than its preset on that preset's inputs:
- A hole that one colour fills is the same shape as the path that fills it, so
  the two still share one outline (a shape beside a fitted hole left slivers).
- A rectangle is as large as where its sides lie on average, not their farthest
  points.
- Auto Grouping keeps the paths of a group in the order they were traced in. It
  put a path first and what is inside it on top: a white counter traced under
  its black letter was then painted over the letter's edge, a light seam
  (Default with Auto Grouping, edge dE 10.24 -> 9.88; legacy 10.05).

The harness measures every enhanced preset beside its preset.
2026-10-14 06:43:57 +07:00
bflatastic 638d55983f Image Trace: stroked lines get square caps where their ink is cut square
Create: Strokes drew every line with round caps: a square-ended line 10 px wide
had its corners 2.94 px outside the stroke.

- A line's ends are square when its ink reaches as far a third of the width to
  either side of its middle as along it (a round end stops short there by a
  seventh of the width); told from lines 3 px wide and more, all the ends of a
  line together. The stroke then has square caps (`TracedPath::square_caps`).
- The last points of a wide line's skeleton can turn towards a corner of its
  end. An end that has left the line the skeleton followed a width back (by a
  quarter of the width, 1 px at least) is put back on it; and an end may be
  pulled back as well as pushed out to where the ink ends.

Outline on the thin-line inputs: largest distance from the art 2.94 -> 0.88 px
(clean 2.94 -> 0.41; aliased 0.88 -> 0.81), mean 0.055 -> 0.050 px, anchors
14 -> 10.
2026-10-14 06:43:57 +07:00
bflatastic c6d9822f26 Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
- A sharp corner may fall up to 0.75 px short within 3 px of where it should be:
  anti-aliasing and blur take that much off a corner (not on a thin bar, which
  is all corners).
- A rounded rectangle gets the radius that fits its outline best, searched
  around the one its area says, instead of that one alone.
- A rectangle's direction is tried along its longest straight side, the longer
  axis of its area and the pixel grid: a blurred one may have no straight side.
- A regular polygon's sides, corners and size are read from the outline's shape
  (how its distance from the middle goes round, and its area), not from how many
  anchors the fitter gave it.
- An outline without anti-aliasing is known to half a pixel: it gets half as
  much room again.

Plain shapes found with Shapes on, all sizes and variants: Default 57% -> 98%,
6 Colors 62% -> 100%. The harness counts false shapes: none on any input but one
thin line (a rectangle 2 px wide) whose trace is 0.70 px off with or without
Shapes.
2026-10-14 06:43:57 +07:00
BFlat fe83800ed5 Merge pull request #1823 from dotCooCoo/feat/hidden-characters-kept-layout
Type: Show Hidden Characters keeps each text object's marks between frames
2026-10-14 06:15:01 +07:00
BFlat 567f5edaf3 Merge pull request #1819 from dotCooCoo/feat/rotate-view-keeps-old-texture
Canvas: the last frame turns with the view until the frame at the new angle is ready
2026-10-14 06:14:35 +07:00
BFlat 0a6a047fd9 Merge pull request #1817 from dotCooCoo/feat/translucent-type-layout-kept
Render: type with transparency, a blend mode or raster effects keeps its layout between frames
2026-10-14 06:14:20 +07:00
BFlat 534f00dfd3 Merge pull request #1820 from dotCooCoo/feat/font-alias-lookup-memo
Fonts: a missing font's name is looked up once until fonts are added or rescanned
2026-10-14 06:13:59 +07:00
BFlat c5b2495291 Merge pull request #1825 from DWAA1660/fix/1729-verified-pngsuite
Verify pinned PngSuite downloads before publishing corpus files
2026-10-14 06:13:36 +07:00
BFlat 7dd72dee58 Merge pull request #1815 from DWAA1660/fix/1709-control-event-budget
Bound synthetic control input and per-frame request processing
2026-10-14 06:13:09 +07:00
BFlat d6b63fbc87 Merge pull request #1837 from storytold/docs/progress-1014a
Docs: ROADMAP 2026-10-14 adds #1558–#1808 and Image Trace #1647, #1657; guide position in the Transform panel is a convenience
2026-10-14 06:12:40 +07:00
dotCooCoo fb905ae911 Type: Show Hidden Characters keeps each text object's marks between frames
With Type › Show Hidden Characters on, the canvas laid out every
visible text object in view in every frame to place the dots on
spaces and the ¶ and # marks. With 1,000 two-line labels in view,
drawing the marks took a median of 19.7 ms per frame in a release
build.

The overlay now keeps each text object's marks in document space,
together with the node it laid them out from. In a frame it lays out
a text object only when its node is a new `Arc` (an edit makes one)
or the frame before did not draw it, and it lays out all type in view
again when the fonts or the hyphenation exceptions change. The marks
are still placed for the current view in every frame. With the same
labels, drawing the marks takes 0.8 ms per frame, and the shapes drawn
are the same as before.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
bflatastic 841ea1716d Canvas: the art texture's quads and the transparency grid share one textured-quad helper 2026-10-14 06:01:10 +07:00
dotCooCoo 7975b07b22 Render: type with transparency, a blend mode or raster effects keeps its layout between frames
Only type at full opacity, in Normal blending and without object
effects took its glyph outlines from the geometry the renderer keeps
for each type object. Type at less than 100% opacity or in a blend
mode, and the art drawn for type with a drop shadow, glow, blur or
other raster effect, were laid out again in every frame.

draw_text now takes the geometry from text_geom_of when the type it
draws is fx_owner, the node draw_arc is drawing, and the frame does
not snap type to pixels. While the effect-art cache keeps type art,
that art is drawn as fx_owner. The geometry is laid out again when the
type is edited or the fonts change, and frames with Type Optimized
anti-aliasing lay type out as before. On 8 threads at 2880 x 1800, a
page of 1,000 type objects at 80% opacity takes 13 ms a frame instead
of 23 ms, with the same pixels.

Opaque type kept the layout of earlier frames when a symbol it shows
as an inline graphic changed size and the document's inline graphics
had not been resolved again. text_geom_of now lays type out without
the cache when the text it is given is not the node's own, such as the
copy that Document::inline_resolved makes with the new size.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
dotCooCoo a1a27636e1 Fonts: a missing font's name is looked up once until fonts are added or rescanned
For a name that is neither a loaded nor an installed family,
FontDb::canonical built the list of names of every loaded face on
every call. Layout asks for a style's face for each span it shapes
and three more times per paragraph, so the time to lay out type set
in a font that is not installed grew with the number of fonts loaded:
a story of 1,000 paragraphs took a median of 409 to 507 ms in a
release build with 400 fonts added.

The database now keeps canonical's answer for each such name and style
until fonts are added or rescanned, and it keeps at most 4,096
answers. The same story takes a median of 56 to 71 ms, and canonical
gives the same answers as before.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
bflatastic 33a5f2a801 Docs: the roadmap lists the interaction pass and the hardening milestones once each 2026-10-14 06:01:10 +07:00
DWAA1660 fdf6ba9c9d M1: bound synthetic control input expansion (#1709) 2026-10-14 06:01:10 +07:00
bflatastic f17ecf4f5c Docs: ROADMAP 2026-10-14 adds #1558–#1808 and Image Trace #1647, #1657; guide position in the Transform panel is a convenience, not a parity claim 2026-10-14 06:01:10 +07:00
dotCooCoo b3ac169f00 Canvas: the last frame turns with the view until the frame at the new angle is ready
When the last frame took more than 8 ms to render, the desktop app's
canvas sends the next frame to the render worker and draws the last
one until the new one arrives. After a pan or a zoom it draws the last
frame moved and scaled. After the view angle changed, it drew no art
until a frame at the new angle arrived. The angle changes during a
Rotate View drag and when the status bar's angle menu, Reset Rotate
View or a saved view sets it. In a headless run of the canvas on a
document of 50,000 paths, none of the 60 frames of a turn from 0° to
90° drew any art.

The canvas now draws the last frame as a mesh turned into the new
view. Each corner goes where the new view puts the document point that
the corner showed. The frame at the new angle replaces it when it
arrives, as before. Pixel Preview's frames already turned with the
view and are drawn as before.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
DWAA1660 dfb0ce36bd M14: verify and bound PngSuite corpus downloads (#1729) 2026-10-14 06:01:10 +07:00
BFlat dbd2497b82 Merge pull request #1632 from sanyamsingh001/codex/r61-screens-tiff
G13: offer TIFF in Export for Screens and Asset Export
2026-10-14 05:42:41 +07:00
BFlat f9bdad7ee6 Merge pull request #1789 from osamaamin2011/dxf-centerlines
DXF export: strokes as zero-width centerlines, a 3ds Max preset, and one VPORT view direction
2026-10-14 05:42:21 +07:00
BFlat 1a7ad48633 Merge pull request #1799 from switch87/fix/1557-css-not
SVG import: style sheet rules with :not() selectors apply (#1557)
2026-10-14 05:42:02 +07:00
BFlat 32950368b3 Merge pull request #1631 from sanyamsingh001/codex/r61-artboard-delete
G1: keep Artboard deletion consistent across controls
2026-10-14 05:41:46 +07:00
BFlat a55453d89c Merge pull request #1634 from sanyamsingh001/codex/r61-style-range-fix
G4: capture character styles from selected text
2026-10-14 05:41:28 +07:00
BFlat 8d2aef8cd9 Merge pull request #1633 from sanyamsingh001/codex/r61-style-auto-leading
G4: propagate character-style Auto-leading resets
2026-10-14 05:40:46 +07:00
BFlat e1c0d92a5e Merge pull request #1784 from sanyamsingh001/codex/r62-paragraph-alignment
G4: show mixed paragraph alignment without a selected button
2026-10-14 05:40:20 +07:00
BFlat 316611374e Merge pull request #1782 from sanyamsingh001/codex/r62-guide-alt
G1: switch new ruler-guide orientation with Alt
2026-10-14 05:39:41 +07:00
BFlat db0c8e93f0 Merge pull request #1814 from sanyamsingh001/codex/r62-width-create
G1: create width points through Width Point Edit
2026-10-14 05:39:24 +07:00
BFlat 5ce38c7b2a Merge pull request #1630 from sanyamsingh001/codex/r61-width-shift
G1: honor Shift-double-click in Width Point Edit
2026-10-14 05:39:08 +07:00
BFlat a23f5711af Merge pull request #1809 from Messhias/feat/394-identify-objects-by
Part of #394: Identify Objects By (Object Name / XML ID)
2026-10-14 05:38:49 +07:00
BFlat 327170a8e8 Merge pull request #1750 from Messhias/feat/394-black-output
Part of #394: Appearance of Black › Printing / Exporting
2026-10-14 05:38:34 +07:00
BFlat 216d82a3fd Merge pull request #1682 from Gamedirection/feat/corner-radius-fields
Corner Radius: a field per corner of a live rectangle, with a link toggle
2026-10-14 05:38:16 +07:00
BFlat 449b0d4056 Merge pull request #1670 from AyberkA0/feat/3d-documented-lighting
3D and Materials: the light takes the documented settings
2026-10-14 05:37:56 +07:00
BFlat 646abb3c5c Merge pull request #1669 from AyberkA0/feat/3d-perspective-angle
3D and Materials: Perspective runs 0° to 160°, as documented
2026-10-14 05:37:24 +07:00
bflatastic e2b9705770 Docs: merge rows that two changes edited 2026-10-14 05:16:26 +07:00
sanyamsingh001andCodex cfec15d8d9 G13: offer TIFF in Export for Screens and Asset Export
Co-authored-by: Codex <[email protected]>
2026-10-14 05:16:22 +07:00
bflatastic a57aea8e94 DXF export: leave out the centerline option and the 3ds Max preset for now
The VPORT view direction fix and its test stay. The Export Strokes as
Centerlines option and the preset button are not in the documented DXF/DWG
export options, so they wait for the owner's decision.
2026-10-14 05:16:15 +07:00
Osama Amin 4a68f84282 DXF export: strokes as zero-width centerlines, a 3ds Max R12 linework preset, and one view direction in VPORT 2026-10-14 05:16:09 +07:00
bflatastic 301a99d91c SVG import: nested :not() selectors are bounded 2026-10-14 05:16:03 +07:00
Gert Pellin 6f9595542d SVG import: no slice indexing when writing a new style attribute 2026-10-14 05:16:00 +07:00
Gert Pellin 5f8c4e9ab5 SVG import: style sheet rules with :not() selectors apply (#1557)
usvg's style sheets (simplecss) drop rules whose selector has a :not(), so
rect:not(.keep) { fill: green } left the rect as it was. The text pass's own
cascade now reads :not() of a list of compound selectors (counted as its most
specific argument), and the import pre-pass writes what such rules set on an
element into its style attribute, with the value that wins the cascade there,
so usvg reads what CSS would: against other rules, the style attribute and
!important, inherited from groups, display: none and CSS transforms included.
Selector lists are split at top-level commas only.
2026-10-14 05:15:59 +07:00
sanyamsingh001andCodex bb07d267aa G1: keep Artboard deletion consistent across controls
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:42 +07:00
sanyamsingh001andCodex 5f4da3beb2 G4: capture character styles from selected text
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:34 +07:00
sanyamsingh001andCodex 123b8ad8ea G4: propagate character-style Auto-leading resets
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:30 +07:00
sanyamsingh001andCodex 0c2c98f6e7 G4: show mixed paragraph alignment without a selected button
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:18 +07:00
sanyamsingh001andCodex ff6aaaaeea G1: switch new ruler-guide orientation with Alt
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:12 +07:00
sanyamsingh001andCodex 0e6b2e331d G1: create width points through Width Point Edit
Co-authored-by: Codex <[email protected]>
2026-10-14 05:14:20 +07:00
sanyamsingh001andCodex f7714c539f G1: honor Shift-double-click in Width Point Edit
Co-authored-by: Codex <[email protected]>
2026-10-14 05:13:59 +07:00
bflatastic bc50c7fa56 Identify Objects By: rustfmt 2026-10-14 05:13:36 +07:00
Fabio William Conceição 958c1dffe8 Part of #394: Identify Objects By shows and edits Layers names as XML IDs 2026-10-14 05:13:15 +07:00
bflatastic cfa65a2148 Appearance of Black › Printing / Exporting: cover ink-plane documents and CMYK exports
Drops the documented richBlack parameter: commands don't list it.
2026-10-14 05:12:59 +07:00
Fabio William ConceiçãoandCursor b6d14b1fd0 Part of #394: Appearance of Black › Printing / Exporting draws pure CMYK black as RGB black
Co-authored-by: Cursor <[email protected]>
2026-10-14 05:11:55 +07:00
GameDirection b5bc8f0d19 Corner Radius: a field per corner of a live rectangle, with a link toggle
The Corner Radius field in the Properties and Transform panels has a
link toggle behind it (the "singleCornerRadius" preference, on by
default). When the link is on, one field sets every corner, as before.
When it is off, the panels show one field for each corner of a live
rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Each field
sets its own corner through object.setLiveShape {corners, radius}.

This is the part of #1594 that does not change the corner widget drag
or its modifier keys, split out for review.

Tests: a Properties panel test (the link on shows one field; off shows
the four labelled fields with each corner's radius, in line and in
order). The new labels are translated in all 15 catalogs.
2026-10-14 05:11:22 +07:00
Ayberk A. 043b446b94 3D and Materials: the light takes the documented settings
The public 3D and Materials panel options give the Light Settings as
Intensity (0–100 %), Rotation (-180° to 180°), Height (0° to 90°),
Softness (0–100 %) and Ambient Light (0–200 %), with the light moved to
the back or the front of the object. The 3D effects (Revolve included)
had Direction and Elevation (an azimuth and elevation), Intensity and
Ambient (0–100 %).

- Rotation turns the light round the object on the page (our reading:
  0° on its right, counterclockwise); Height raises it from beside the
  object (0°) to straight in front (90°); Light behind the object puts
  it at the back. The light gizmo's ring and line are Rotation and
  Height.
- Softness wraps the light round the object: at 0 % it falls off with
  the angle as before, at 100 % it reaches nothing only straight away.
- Ambient Light runs to 200 %; faces stay no brighter than the art.
- lightRotation, lightHeight, lightBehind and lightSoftness replace
  lightAzimuth and lightElevation. An earlier light is read as the same
  direction, so it shades exactly as before (old and new code give the
  same colours for the four effects and five lights); effect.setParams
  turns a stored one into the new form before applying new values, and
  the parameter description names lightAzimuth and lightElevation as the
  earlier form, so effect.apply and effect.setParams still take them. The
  default light is the earlier default's: Rotation 125.26°, Height 30°.
- The dialog's Lighting tab shows the settings in the documented order;
  Softness, Ambient Light and Light behind the object are in every
  catalog, and the unused Direction, Elevation and Ambient are gone.

Light colour, presets, several lights and shadows are not built.
2026-10-14 05:10:48 +07:00
Ayberk A. 39db6abfce 3D and Materials: Perspective runs 0° to 160°, as documented
The public 3D and Materials panel options give Perspective as 0° to 160°
("how close or far the object appears"); it was a 0–100 % strength, for
Revolve, Extrude & Bevel, Inflate and Rotate alike.

- The eye stands where a sphere about the object's centre, √3 times its
  half size (the box around it fits inside), fills that angle of the
  view: 0° is none, wider brings the eye nearer, and at 160° it is just
  outside the sphere, never inside the object. The documentation doesn't
  say how the angle places the eye; docs/three-d.md says this is our
  reading.
- `perspectiveAngle` (degrees) replaces `perspective` (%). A strength from
  an earlier file or script is read as the angle that shows exactly the
  same view: sin(angle / 2) = √3 × strength / 4, 100 % about 51.3°. An
  angle given too wins. effect.setParams turns a stored strength into the
  angle before applying new values. The parameter description names
  `perspective` as the earlier form, so effect.apply and effect.setParams
  still take it under the key check of #1592. Old and new code draw the four effects identically at 0, 25,
  60 and 100 % (bounds equal to 1e-9).
- The dialogs' Perspective fields run 0–160°.

# Conflicts:
#	docs/target-app-parity.md
2026-10-14 05:10:09 +07:00
BFlat a3357121fb Merge pull request #1792 from dotCooCoo/feat/thread-reflow-copies-only-changed-stories
Type: an edit copies a threaded story only when it changes one of its frames
2026-10-14 05:07:09 +07:00
BFlat 81d9e37e8f Merge pull request #1767 from dotCooCoo/feat/open-resolves-links-off-ui-thread
Open: the opening thread also reads linked files and lays out type
2026-10-14 05:06:41 +07:00
BFlat bb70aa8359 Merge pull request #1744 from dotCooCoo/feat/symbol-instance-art-bounds
Symbols: instances of imported symbols are measured by their art
2026-10-14 05:06:05 +07:00
BFlat 9258c4ea57 Merge pull request #1690 from dotCooCoo/feat/svg-style-rule-index
SVG import: the style cascade looks up each element's rules by id, class and type
2026-10-14 05:05:34 +07:00
BFlat b0ec580828 Merge pull request #1755 from dotCooCoo/feat/cmyk-transparency-planes
Render: the ink planes of CMYK documents with transparency are composed on several threads
2026-10-14 05:04:55 +07:00
BFlat 03e6657e93 Merge pull request #1689 from dotCooCoo/feat/outline-mode-path-caches
Render: Outline view draws paths and clipped art from geometry kept between frames
2026-10-14 05:04:17 +07:00
BFlat aeda9de044 Merge pull request #1688 from dotCooCoo/feat/live-effects-follow-document-edits
Render: live objects are evaluated again when patterns, symbols, fonts or color settings change
2026-10-14 05:03:40 +07:00
BFlat 35ecdf2142 Merge pull request #1687 from dotCooCoo/feat/group-backdrop-region
Render: the canvas no longer draws its picture back for each non-isolated group that blends
2026-10-14 05:02:02 +07:00
BFlat 1b54bc335d Merge pull request #1667 from dotCooCoo/feat/stroke-outlines-per-object
Render: objects that share an appearance keep their own stroke outlines between frames
2026-10-14 05:01:35 +07:00
dotCooCoo 4844d54fda Type: an edit copies a threaded story only when it changes one of its frames
After every edit of a document with threaded text, each frame of a
drag included, threads::reflow copied the runs and the text object of
every frame of every thread, and then dropped the copies for each
thread whose frames the edit had not changed. With a story of 50,000
characters threaded through 8 frames with a style change at every word
(9,618 runs), moving another object took 7.8 ms in a release build,
and a frame of a drag took 7.2 ms.

reflow now copies a thread's story only when one of its frames
changed. The same move takes 0.013 ms, and the drag frame takes
0.012 ms. Threads with a changed frame re-flow as before, and the
documents and undo steps after edits are the same as before.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo 85cea7efea Open: the opening thread also reads linked files and lays out type
When the desktop app opened a document on a thread of its own, that
thread parsed it, but open_read, which the app ran on the UI thread,
read the document's linked files, decoding BMP, PSD and RGB TIFF files
and storing them again as PNG, and laid out the type. In release builds,
a document with 12 linked 3000 x 2000 TIFFs kept the UI thread busy for
1.04 s, and one with 300 Auto Size area type objects, every second one
in three columns, kept it busy for 0.85 s.

read_for_open now reads the linked files and lays out the type on the
opening thread, with the path and the Update Links preference that
opening::start passes it, and open_read only adds the finished document.
open_read now takes 0.02 ms and 0.1 ms on those documents. For the
measured documents, the opened documents, results and undo states are
the same as on main.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
bflatastic 37174ac306 Render tests: the symbol art bounds test holds the fonts steady (#1744) 2026-10-14 04:56:30 +07:00
dotCooCoo 21a386a6d4 SVG import: the style cascade looks up each element's rules by id, class and type
The SVG import's style cascade tested every <style> rule against each
element it was asked about. The import asks about the objects outside
<defs> and the like when the file contains `display`, about each shape
when the file contains `non-scaling-stroke`, and about each <text>, its
spans and its ancestors. On a file with 2,000 class rules and 100,000
rectangles, `vectorcraft-cli info` took 3.54 s with one `display`
attribute in the file and 2.03 s without it.

The rules are now filed under the id of their selector's rightmost
compound, else under its first class, else under its type. An element
is tested against the rules filed under its id, its classes and its
type, and the rules filed under none of these, in source order. It
matches the same rules in the same order and gets the same
properties. The file with the `display` attribute now takes 2.10 s.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo f9cbcfb046 Render: the ink planes of CMYK documents with transparency are composed on several threads
A CMYK document that shows transparency is drawn as two ink planes,
and ink::compose turned them into screen colors one pixel at a time on
one thread. At 2880 x 1800 that took about as long as drawing one
plane of 50,000 paths. The renderer also searched a CMYK document for
transparency in every frame.

compose now splits the frame into bands, as many as the renderer has
threads and no more than one per 65,536 pixels. The calling thread
composes the first band and a scoped thread composes each other band,
with the same per-pixel code. The renderer keeps the answer of the
transparency test with the layers, symbol art and pattern art it was
found for, held weakly, and tests again only when the document holds
other art. With Overprint Preview on, a document whose layers or
symbols hold an overprinting fill or stroke is still tested in every
frame. FrameStats::ink_threads reports how many threads composed a
frame. On 8 threads at 2880 x 1800, a page of 300 shapes at 20% to 90%
opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with
one at 99% opacity take 258 ms instead of 361 ms, with the same
pixels.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo 2ce6f90522 Render: Outline view draws paths and clipped art from geometry kept between frames
In Outline view the renderer made a new path from the path data of
each path and compound path it drew, in every frame. In Preview the
renderer draws plain paths from the path it keeps for each object
between frames. In Outline view the members of a clip group were
culled by bounds measured again in every frame, and the bounds of a
group inside a clip group were measured from every path in it. The
outlined shapes of an Image Trace object were culled and drawn the
same way.

In Outline view the renderer now draws paths and compound paths from
the paths it keeps for them, and culls the members of clip groups and
the outlined shapes of Image Trace objects by the bounds it keeps for
them. The live-effect geometry of those members is kept between frames
too. The frames have the same pixels as before.

With 50,000 paths at 2880x1800 on 8 threads, an Outline frame at 400%
took 47.1 ms on main and takes 16.7 ms when the paths are the members
of one clip group. It took 36.6 ms and takes 26.3 ms when the paths
are paired into compound paths.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
bflatastic caa80fb213 Render tests: the live object tests take the font locks (#1688) 2026-10-14 04:56:30 +07:00
dotCooCoo d9bd9b134d Symbols: instances of imported symbols are measured by their art
Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.

An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.

Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.

The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.

Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo 78980c8190 Render: the canvas no longer draws its picture back for each non-isolated group that blends
A transparency group whose members blend with the art below it, and a
masked object with a blend mode, are drawn over a copy of that art. For
each such group in each frame, the renderer rendered everything drawn
so far into a new pixmap the size of its context (on the canvas, the
whole view), started that context again and drew that pixmap back into
it as an image, which the next copy and the frame rendered again. At
2880x1800, a document with 50 translucent groups, each holding a
Multiply rectangle, took 299 ms a frame on 8 threads.

Every render that composites in 8 bits, the canvas included, now keeps
that picture and renders what the context draws next over it. The
backdrop is still cut from the picture. The 50 groups take 65 ms a
frame, and the frames have the same pixels. Renders that composite in
floating point (raster exports, Save for Web, Object › Rasterize and
Crop Image) still draw the picture back.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:29 +07:00
bflatastic 819e53fe92 Render tests: the stroke outline tests hold the fonts steady (#1667) 2026-10-14 04:56:29 +07:00
dotCooCoo 6504dc7da2 Render: live objects are evaluated again when patterns, symbols, fonts or color settings change
The renderer kept the evaluation of each blend, envelope and repeat
while the object and a hash stayed the same: the addresses of the art of
the symbols the object showed and of the patterns it painted with, and
the patterns' tile bounds. When anything else the evaluation reads
changed, such as a pattern's tile type, symbols in symbol art, the
fonts or the color settings, a renderer that had drawn the object went
on drawing the old evaluation. The quads of a gradient mesh were kept
the same way after a change to the color settings, and the art drawn
for a live object with an effect of its own was kept by the object's
address alone.

resources_key now also walks the opacity-mask, symbol and pattern art
it finds. It hashes what it finds, the font generation when it finds
type or a placed document, and the color settings. Mesh quads are made
again when the color settings change. The art drawn for an object's
effects is made again when the hash changes, if it was made from
evaluated live objects.

With other builds running, a temporary release test timed the hash for
400 live objects at 17 to 506 microseconds a frame, against 10 to 16
before.

Docs: docs/target-app-parity.md, docs/gaps.md.
2026-10-14 04:56:29 +07:00
dotCooCoo 9f0a9a362b Render: objects that share an appearance keep their own stroke outlines between frames
The renderer kept the outline of a stroke under the address of the
stroke in the object's appearance, the bits of the width and the
tolerance level, and used an entry only when the entry held the object
being drawn. The slices of a gradient along or across a stroke were
kept the same way. Objects can share one appearance until one of them
changes it. Copies made by Duplicate Selection share the original's
appearance, gradients included. For objects without a gradient fill or
stroke, so do the art the renderer places for unstained symbol
instances, the copies a live repeat draws and copies made by
edit.duplicate. In each frame, the objects that share an appearance
replaced each other's entries. The renderer therefore outlined their
strokes again in every frame.

The two caches now key each entry by the address of the object as
well, and each object keeps its own outlines from frame to frame. Art
that the renderer makes again in every frame keeps the old key: symbol
art that placed_art does not keep, and the evaluations of live objects
and the art of objects with effects that their caches do not keep. The
copies in such art replace each other's entries as before.

vectorcraft-cli bench of 2,000 unstained instances of a symbol of
eight paths took 45.7 ms a frame on main and 31.1 ms with this change,
at 2880x1800 on 8 threads, and a grid repeat of the same paths, which
draws 2,000 groups, took 45.0 ms on main and 27.2 ms with this change.
The peak working set of a bench run on the instances went from 88 MiB
to 122 MiB. In the tests, each frame draws what a new renderer draws.
2026-10-14 04:56:29 +07:00
BFlat cd89392ac9 Merge pull request #1739 from KnottyDyes/squad/remote-stale-connection
MCP: reconnect closed control sockets before sending requests
2026-10-14 04:43:14 +07:00
BFlat db1d586674 Merge pull request #1768 from fahrim/fix/1728-jsonrpc-version-and-reply-id
Fix #1728: require the JSON-RPC 2.0 envelope and correlate control replies
2026-10-14 04:42:45 +07:00
BFlat 7543ef966c Merge pull request #1764 from fahrim/fix/1727-framing-limits
Fix #1727: cap MCP framing input, batches and kept command_batch results
2026-10-14 04:42:21 +07:00
BFlat 67f131d786 Merge pull request #1797 from DWAA1660/fix/1726-control-deadlines
Fix control sockets retaining connection slots indefinitely
2026-10-14 04:41:56 +07:00
bflatastic 2366fcfd80 docs/mcp.md: the server reconnects to the app before a call when the connection was closed 2026-10-14 04:31:44 +07:00
bflatastic 7e56066847 MCP: property test for the framing, batch and envelope limits; the refusal without an id is documented (#1727, #1728) 2026-10-14 04:31:44 +07:00
bflatastic 63b0dc4c32 MCP: a connection holding bytes nobody asked for is replaced before the request is sent 2026-10-14 04:31:44 +07:00
bflatastic d3327b17a1 MCP: a control refusal without an id reports the app's reason instead of an id mismatch (#1728) 2026-10-14 04:31:44 +07:00
KnottyDyesandClaude Opus 5.5 16366ce422 MCP: reconnect closed control sockets before sending requests
After the app restarted, the remote backend wrote the next request to
the old, closed connection and reported that the operation may have
completed, although the restarted app received nothing. Reused
connections are now checked without blocking before writing, and a
closed one is replaced first. If the app can't be reached, the error
says so and that nothing was sent. Failures after the request was
written still report uncertainty and are never replayed.

Loopback tests cover a restart between calls (the edit is applied
exactly once) and a refused reconnect. The lost-reply test from #1246
still passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
2026-10-14 04:31:44 +07:00
Fahri Meral cc518797a8 Fix #1728: require the JSON-RPC 2.0 envelope and correlate control replies
The server dispatched requests and notifications without ever checking jsonrpc, so a missing or wrong version could invoke tools or flip notification state; Remote accepted any reply with ok/result/error without comparing ids, so a faulty endpoint could attribute a stray result to the current call. handle() now requires jsonrpc 2.0 before dispatch (other envelopes get -32600, or are ignored for notifications, which have no id for a reply) and Remote::call rejects a reply whose id doesn't echo the request's, dropping the connection so the next call starts fresh, with the usual uncertainty error. Three new tests cover wrong/missing versions (incl. batch entries and a state-changing notification) and the mismatch-reconnect; docs/mcp.md documents both. cargo test -p vectorcraft-mcp passes.
2026-10-14 04:31:44 +07:00
bflatastic 5336d80196 command_batch: the step whose result passes the size cap says that it ran (#1727) 2026-10-14 04:31:43 +07:00
bflatastic 85c81ff1e9 Control: an idle connection is closed without a line, so no error waits where a client's next reply would be (#1726) 2026-10-14 04:31:43 +07:00
Fahri Meral b75ced8a70 Fix #1727: cap MCP framing input, batches and kept command_batch results
The server read stdio lines and remote replies without any byte bound, answered batches of any size, and command_batch kept every step result: one faulty peer or batch could allocate and work without bound. Request lines are now read in bounded chunks (16 MiB; longer lines get a -32600 reply, are skipped, and framing continues), protocol batches hold at most 64 messages, control-channel replies past 256 MiB drop the connection with the usual uncertainty error, command_batch takes at most 256 steps and keeps at most 8 MiB of step results (past that the batch stops with the reason, still closing its atomic undo group). Eight new tests cover the line/batch/step/result caps and the atomic-group close; docs/mcp.md and the command_batch tool text document them. cargo test -p vectorcraft-mcp passes.
2026-10-14 04:31:43 +07:00
DWAA1660 a0bd1fd16b M1: bound control connection transport waits
Fix #1726 with idle, absolute line and reply-write deadlines and connection-slot guards. Add real-loopback regressions and document reconnect behavior.

Validated with cargo xtask ci: all seven gates passed; 6156 tests passed, zero failed, seven existing ignored.
2026-10-14 04:31:43 +07:00
BFlat f8396dbc4f Merge pull request #1663 from ham9zah/pen-drag-threshold
Pen: drag threshold, Precise Cursors command and tests
2026-10-14 04:28:59 +07:00
BFlat fe027037d2 Merge pull request #1795 from danngreen/fix/render-tests-font-race
Fix race over font database in tests
2026-10-14 04:28:44 +07:00
BFlat 1369bd0c93 Merge pull request #1785 from sanyamsingh001/codex/r62-import-fuzz
CI: isolate text import fuzzing from installed fonts
2026-10-14 04:28:26 +07:00
BFlat c2a944a686 Merge pull request #1677 from marcoarnulfo/fix/color-picker-remembers-mode
Color panel: the mode picked last comes back after a restart
2026-10-14 04:28:10 +07:00
BFlat e02ffe9513 Merge pull request #1752 from switch87/fix/1748-point-type-align
Fix #1748: text.create takes justify for point type alignment
2026-10-14 04:27:52 +07:00
BFlat a06899fdd0 Merge pull request #1746 from marcoarnulfo/fix/select-same-type-paint
Select > Same: type matches on its characters' fill and stroke
2026-10-14 04:27:35 +07:00
BFlat 422a5d7eb9 Merge pull request #1765 from AyberkA0/fix/1756-expand-repeat
Fix #1756: Object → Expand expands repeats
2026-10-14 04:27:19 +07:00
BFlat 5fb311fb60 Merge pull request #1808 from AyberkA0/fix/1662-ui-scale-slider-medium-dark
Fix #1662: slider rails show on Medium Dark
2026-10-14 04:27:03 +07:00
BFlat 1a506fae4c Merge pull request #1798 from AyberkA0/fix/1757-palette-arrows
Fix #1757: arrow keys move through the command search results
2026-10-14 04:26:46 +07:00
BFlat 3298c5088c Merge pull request #1734 from AyberkA0/fix/1661-paintbrush-brackets
Part of #1661: [ and ] reach the tools that take them
2026-10-14 04:26:29 +07:00
BFlat 951bf42714 Merge pull request #1804 from AyberkA0/fix/1679-pencil-keeps-appearance
Fix #1679: a filled pencil path keeps a stroke of None
2026-10-14 04:25:57 +07:00
BFlat 7d5c4b4b9d Merge pull request #1802 from AyberkA0/fix/1686-esc-text-edit
Fix #1686: Escape in type editing goes to the Selection tool
2026-10-14 04:25:41 +07:00
BFlat 6187a3f462 Merge pull request #1803 from AyberkA0/fix/1743-esc-cancels-point-drag
Fix #1743: Escape cancels a Direct Selection drag
2026-10-14 04:25:22 +07:00
BFlat 589797adef Merge pull request #1666 from turtwig/main
Direct Selection stays inside isolation mode
2026-10-14 04:25:03 +07:00
BFlat 043ea1d525 Merge pull request #1674 from turtwig/fix/delete-selected-anchors
Delete removes every selected anchor and keeps the remainder selected
2026-10-14 04:24:46 +07:00
BFlat a1eb4fc090 Merge pull request #1791 from AyberkA0/fix/1758-cursor-hotspot
Fix #1758: keep the custom title bar window borderless on Windows
2026-10-14 04:24:20 +07:00
ham9zahandClaude Sonnet 5.5 cde7b0ece7 Pen: Space pressed inside the drag threshold moves the anchor by the next move only
The drag threshold kept `last` at the press position until it was crossed, so
Space held when the move that crossed it came moved the anchor by the whole
distance since the press. `last` now follows the pointer while the threshold
swallows the moves, and the threshold is measured from a separate `pressed`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
Dan Green 96d5a14738 Render tests: tests that add fonts no longer run alongside the frame cache tests
tests_fontchange adds fonts to the shared font database, which makes every renderer lay type
out again; a tests_memory test counting the type it kept between frames then failed when one ran
alongside it (about half of the runs of the render tests). The font tests now hold a lock alone,
and the frame cache tests hold it shared.
2026-10-14 03:55:41 +07:00
sanyamsingh001andCodex 4944facd0a CI: isolate text import fuzzing from installed fonts
Co-authored-by: Codex <[email protected]>
2026-10-14 03:55:41 +07:00
Marco Arnulfo e11867128e Color panel: the mode picked last comes back after a restart (fixes #1675) 2026-10-14 03:55:41 +07:00
Gert Pellin ed1e976264 Fix #1748: text.create takes justify, so point type can be centred on its point
text.create, text.createInPath and the MCP add_text tool now take a
`justify` (auto|left|center|right|justifyAll, as text.setStyle). Point
type already laid out centred or right-aligned text around its origin;
it just could not be made that way, so scripts measured the bounds and
moved the text. Changing it afterwards stays text.setStyle {justify}.
2026-10-14 03:55:41 +07:00
Marco Arnulfo d9328c7668 Select > Same: type matches on its characters' fill and stroke (fixes #1737) 2026-10-14 03:55:41 +07:00
Ayberk A. 2f4cc8a1cc Fix #1756: Object → Expand expands repeats
Expand had nothing for live repeats, so a radial, grid or mirror repeat stayed
live. Its Object option now turns every repeat in the selection (inside groups
too, inner ones first) into a group of its instances, keeping the repeat's id
and place, in the same undo step; the dialog enables Object for a repeat.
Expand Repeat shares the code.
2026-10-14 03:55:41 +07:00
Ayberk A. 3024821c32 Fix #1662: slider rails show on Medium Dark
The rail used the fields' border, which on Medium Dark (the default) is all but
the panel's colour; it now uses the buttons' border, which stands out on every
brightness.
2026-10-14 03:55:41 +07:00
Ayberk A. 5fc0be5b85 Part of #1757: the palette test's frame closure needs no mut 2026-10-14 03:55:41 +07:00
bflatastic 3152436020 Brackets: keep to the tools whose size the keys are documented for
The typed [ and ] now reach the Blob Brush, the Eraser and the Symbolism
tools, whose bracket keys are in the public shortcut lists. A size of the
Paintbrush's own (and path.freehand's scale) is left out until its
behaviour is decided: #1661 stays open for that.
2026-10-14 03:55:41 +07:00
ham9zahandClaude Sonnet 5.5 66a0f457e9 Add a Precise Cursors command and fix the misleading Caps Lock comment
General > Use Precise Cursors already worked and is saved with the other
preferences. This adds a `view.preciseCursors` command that toggles that same
preference, so it can be given a shortcut in Edit > Keyboard Shortcuts. It has
no default shortcut and no View menu item.

The comment in canvas.rs claimed Caps Lock gave precise cursors; nothing reads
Caps Lock (egui has no state for it), so it now describes what the code does.

Tests: for the tools whose own pointer the preference replaces (Pen, the anchor
tools, Curvature, Mesh, Eyedropper, Slice, Blend, Shape Builder) the pointer is
their own when off and a crosshair when on; every state of the Pen's pointer
turns into a crosshair; the preference round-trips through
to_json/apply_prefs; the command toggles it, updates the saved UI state, is
listed in the shortcut editor without a key and can be assigned one; switching
it creates no undo step and leaves the document saved; with system cursors the
glyph fallback is the system crosshair either way. The new label is translated
in the languages that claim complete menus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
Ayberk A. de1ede49a7 Fix #1757: arrow keys move through the command search results
Up and Down move the highlighted result (wrapping at the ends), the pointer
moving over a result highlights it, Enter runs the highlighted one, and a new
query starts from the first again.
2026-10-14 03:55:41 +07:00
Ayberk A. 3f1e72d983 Fix #1661: [ and ] size the Paintbrush, and reach the tools that take them
The tools took `[` and `]` (the Blob Brush's and the Eraser's size, the
Symbolism tools' diameter and, with Shift, intensity), but the app never
passed the keys on: only `ui.key` over MCP reached them.

- The keyboard handler gives a typed `[`, `]`, `{` or `}` (Shift) to the
  active tool when it claims the key. It reads the typed character, so
  layouts where a bracket takes Option or AltGr (Option-8 on a Turkish
  Mac) work too. A field with the keyboard, the Type tool and Cmd/Ctrl
  shortcuts are left as they were.
- The Paintbrush has a size of its own, 10 to start as the Blob Brush's:
  `[` and `]` take a point off or add one, and the next stroke is drawn
  at size / 10 of the stroke weight (path.freehand `scale`). Public
  references describe the keys as changing the size the Paintbrush draws
  with, before drawing, the brush definition left as it is; so the
  stroke weight new art takes stays, and the selected path keeps its
  stroke. Every brush type, as the report and the Tuts+ guide describe;
  the step is our own.
2026-10-14 03:55:41 +07:00
ham9zahandClaude Sonnet 5.5 3ea4802a53 Pen: test that a click after a smooth anchor curves from its side only
Covers the Illustrator behaviour that a corner placed after a smooth anchor
has no handles, so the segment is shaped by the previous outgoing handle alone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
ham9zahandClaude Sonnet 5.5 eace7c749b Pen: ignore pointer movement inside a 3 px drag threshold
A press released within a few screen pixels is a click: the anchor stays a
corner with no handles, as in Illustrator, instead of getting near-zero
handles from a trembling hand. The threshold is in screen pixels, so it
does not change with zoom. It applies to new anchors, closing a path and
pulling a handle out of the last anchor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
Ayberk A. ef2d678cbf Fix #1679: a filled pencil path keeps a stroke of None
The Pencil gave every path a black stroke when the stroke was None. With Fill
new pencil strokes on and a fill set, the path now keeps no stroke; only a path
that would show nothing (no fill, no stroke) still gets black.
2026-10-14 03:55:40 +07:00
Ayberk A. 1e60a572b1 Fix #1686: Escape in type editing goes to the Selection tool
Escape still ends editing (an empty new point type is discarded) and the type
stays selected; the Type tool now claims Escape while editing and asks for the
Selection tool, so tool shortcuts work again without the mouse.
2026-10-14 03:55:40 +07:00
Ayberk A. e80cc9b49b Fix #1743: Escape cancels a Direct Selection drag
Escape while dragging anchors, handles, segments, objects, a type area, a blend
spine or a marquee with the Direct (or Group) Selection tool cancels the drag:
what it moved goes back and the release after it does nothing.
2026-10-14 03:55:40 +07:00
turtwig f297589c76 Direct Selection stays inside isolation mode
Marquee, anchor clicks, blend spines, and Group Selection only pick the
isolated object and what is inside it. A regression test covers a path
outside an isolated group.
2026-10-14 03:55:40 +07:00
turtwig 89501d73c2 Delete removes every selected anchor and keeps the remainder selected
A fully selected object is deleted along with partial anchors on other
paths. Several anchors on one closed path are removed by their original
indexes. What remains of a path stays selected, so a second Delete
removes it.
2026-10-14 03:55:40 +07:00
Ayberk A. cb9fa7042f Fix #1758: keep the custom title bar window borderless on Windows
winit 0.30 gives an undecorated window WS_CAPTION | WS_BORDER; on some Windows
systems the compositor still reserves that frame, so the content is drawn
offset while the pointer isn't, and the cursor no longer points at what is under
its tip. Strip the two styles once and again each frame (winit restores them on
maximize/restore), as PhotoCraft does (storytold/photocraft#2401).
2026-10-14 03:55:40 +07:00
BFlat bb8897a2eb Merge pull request #1787 from MarekSpodBiedry/issue-1637
Fix numeric fields reverting a typed value when clicking away instead of pressing Enter (#1637)
2026-10-14 03:53:08 +07:00
BFlat 4d54fa27e3 Merge pull request #1773 from MarekSpodBiedry/issue-1722
Linked files: a change during a drag is applied after the drag, not lost (#1722)
2026-10-14 03:52:41 +07:00
BFlat 7f93a191c7 Merge pull request #1775 from MarekSpodBiedry/issue-1720
Do not journal a failed live preview at commit (#1720)
2026-10-14 03:52:17 +07:00
BFlat 0f0380b827 Merge pull request #1774 from MarekSpodBiedry/issue-1721
Stamp File Info dates only after a save succeeds (#1721)
2026-10-14 03:51:47 +07:00
BFlat ae6d70ae19 Merge pull request #1781 from MarekSpodBiedry/issue-1702
Search each shared name tree node once when finding the editing file (#1702)
2026-10-14 03:51:18 +07:00
BFlat b72ca300ed Merge pull request #1779 from MarekSpodBiedry/issue-1708
Cap Liquify dabs while expanding a segment, not only before it (#1708)
2026-10-14 03:51:00 +07:00
BFlat 4892384168 Merge pull request #1780 from MarekSpodBiedry/issue-1707
Fix scatter X axis ticks looping forever on data near the f64 maximum (#1707)
2026-10-14 03:49:57 +07:00
BFlat 64f875ff34 Merge pull request #1776 from MarekSpodBiedry/issue-1716
Check embedded PNG/JPEG bitmap size from the header before decoding (#1716)
2026-10-14 03:49:29 +07:00
BFlat 4d0f1dd0f8 Merge pull request #1783 from MarekSpodBiedry/issue-1698
Fix #1698: entries read from memory obey the size limits too, not only streamed ones (#1698)
2026-10-14 03:49:09 +07:00
BFlat 1e6c17c54b Merge pull request #1778 from MarekSpodBiedry/issue-1712
Create the staging file owner-only when replacing a document (#1712)
2026-10-14 03:48:51 +07:00
BFlat 6f56ad2e7f Merge pull request #1777 from MarekSpodBiedry/issue-1713
Keep Export for Screens suffix inside the export folder (#1713)
2026-10-14 03:48:36 +07:00
BFlat b374fee789 Merge pull request #1786 from MarekSpodBiedry/issue-1696
Refuse to package over the source document or its linked files (#1696)
2026-10-14 03:47:20 +07:00
BFlat 9abdbb0a71 Merge pull request #1807 from AyberkA0/fix/1710-ruzstd-0.9.1
Fix #1710: ruzstd 0.9.1 (RUSTSEC-2026-0344)
2026-10-14 03:47:05 +07:00
bflatastic d775549258 A press outside a field commits what was typed before the press acts 2026-10-14 03:17:55 +07:00
bflatastic 8a7e36ede8 Background save edited meanwhile: undoing back to the saved state is unmodified (#1721) 2026-10-14 03:17:51 +07:00
bflatastic 7abc1cfa10 Scatter overflow test: data that far off the plot is refused, not drawn (#1707) 2026-10-14 03:17:49 +07:00
bflatastic d82d6e3620 Test that a hostile Export for Screens suffix or prefix stays in the export folder (#1713) 2026-10-14 03:17:47 +07:00
BFlat 7fc8173253 Merge pull request #1684 from dotCooCoo/feat/mcp-document-reads-faster
Control: replies are buffered, so a 17 MB reply is sent in 0.09 s instead of 34 s on Windows
2026-10-14 03:08:58 +07:00
BFlat 466b4b0180 Merge pull request #1652 from marcoarnulfo/fix/canvas-cursor-wayland-scale
Cursors: paint the tool cursors on Wayland, where the compositor showed the bitmap twice too big
2026-10-14 03:08:35 +07:00
BFlat 65ec1842ac Merge pull request #1654 from switch87/fix/1653-eps-hard-stops
EPS import: a stitched shading keeps its hard colour changes
2026-10-14 03:04:15 +07:00
BFlat ad358fb4e4 Merge pull request #1651 from switch87/fix/1648-scribble-regions
Scribble: one zig-zag per region, no lines across holes and notches
2026-10-14 03:03:54 +07:00
BFlat 0814b70fe0 Merge pull request #1646 from switch87/fix/1643-roughen-cusps
Roughen (Smooth): a turn-back stays smooth instead of becoming a cusp
2026-10-14 03:03:34 +07:00
BFlat 14df0db488 Merge pull request #1642 from marcoarnulfo/fix/shape-modes-on-group
Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroke keeps the Shape Modes enabled
2026-10-14 03:03:12 +07:00
BFlat 70937b59ba Merge pull request #1641 from marcoarnulfo/fix/align-anchors-default-selection
Align: anchor points of one path align to each other, not to the artboard
2026-10-14 03:02:45 +07:00
dotCooCoo 7fde8739a7 Control: replies are buffered, so a 17 MB reply is sent in 0.09 s instead of 34 s on Windows
The control server wrote each reply with writeln!(out, "{r}") on the
bare TcpStream. serde_json formats a Value one piece at a time, and
each piece (a brace, a quotation mark, a key, a colon, a number) was
sent as a write of its own: the 17 MB document.inspect reply of a
document with 50,000 objects on one layer was sent in 7,857,200 writes.
On Windows 11, sending that reply to a client over loopback took 34 s,
and sending the 28 MB document.json reply took 57 s.
vectorcraft-cli mcp reads every reply from a running app over this
connection.

write_line now writes each reply, each refusal and the "too many
control connections" line through a 64 KiB BufWriter, then flushes
it. The 17 MB reply takes 262 writes. Sending it now takes 0.09 s,
and sending the 28 MB reply takes 0.2 s. The bytes are the same.

Docs: docs/target-app-parity.md.
2026-10-14 02:09:15 +07:00
Marco Arnulfo e50bd58146 Cursors: paint the tool cursors on Wayland, where the compositor showed the bitmap twice too big (fixes #1645) 2026-10-14 02:09:09 +07:00
Gert Pellin 3412d3a073 EPS import: a stitched shading keeps its hard colour changes (#1653)
A stitching function's bound became one stop, evaluated at the bound:
the right piece's colour. Where the pieces don't meet, the left piece's
end colour was lost. The colour just before the bound is now a stop at
the same offset too, ahead of the one after it, when the two differ;
continuous stitches keep one stop per bound as before.

Fixes #1653
2026-10-14 02:09:09 +07:00
Gert Pellin 85335e9e14 Scribble: one zig-zag per region, no lines across holes and notches (#1648)
The rows' spans were all chained into one polyline, so where a row had
more than one span the line ran across the hole between them, outside
the shape. A span now continues the previous row's chain whose
connecting line stays inside the shape (the nearest one); otherwise it
starts a chain of its own. In a convex shape the connector always stays
inside, so the result there is unchanged (render goldens pass).

Fixes #1648
2026-10-14 02:09:09 +07:00
Gert Pellin 977b8fb642 Roughen (Smooth): a turn-back stays smooth instead of becoming a cusp (#1643)
Roughen's jitter can put a point behind its predecessor, so the path
turns back. The Catmull-Rom tangent next - prev then shrinks to almost
nothing and the anchor becomes a cusp, which PDF viewers (MuPDF,
poppler, Ghostscript) draw with a miter spike; the preview doesn't.

catmull_rom now keeps the tangent at least half as long as the longer
chord, and where it is shorter (turns of more than about 150 degrees)
points it along the bisector of the two chords: the turn becomes a round
hairpin. Gentler turns keep the plain Catmull-Rom tangent, so Zig Zag,
Scribble and the render goldens are unchanged.

Fixes #1643
2026-10-14 02:09:08 +07:00
bflatastic f05fbfc292 Pathfinder panel: count a group's shapes without copying them 2026-10-14 02:09:08 +07:00
Marco Arnulfo 7ea75b01ba Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroke keeps the Shape Modes enabled (fixes #1635) 2026-10-14 02:09:07 +07:00
bflatastic 359d9bb833 Distribute spaces the selected anchor points too, and its buttons work with the points of one path (#1636) 2026-10-14 02:09:06 +07:00
Marco Arnulfo dcb963e85b Align: anchor points of one path align to each other, not to the artboard (fixes #1636) 2026-10-14 02:09:06 +07:00
Ayberk A. 0d28ee0872 Fix #1710: ruzstd 0.9.1 (RUSTSEC-2026-0344)
0.9.1 enforces the decompressed size of each block, which 0.9.0 didn't
(memory exhaustion on crafted .ai / Affinity zstd data). The manifests now ask
for at least 0.9.1.
2026-10-13 21:14:00 +03:00
MarekSpodBiedry f4f60b6ac6 Fix numeric fields reverting a typed value when clicking away instead of pressing Enter (#1637) 2026-10-13 18:23:24 +02:00
MarekSpodBiedry 06d3bbd6e7 Search each shared name tree node once when finding the editing file (#1702) 2026-10-13 18:22:14 +02:00
MarekSpodBiedry b50519587d Refuse to package over the source document or its linked files (#1696) 2026-10-13 18:19:12 +02:00
MarekSpodBiedry 4c890b4ba1 Fix #1698: entries read from memory obey the size limits too, not only streamed ones (#1698) 2026-10-13 18:18:52 +02:00
MarekSpodBiedry b8744f6d33 Stamp File Info dates only after a save succeeds (#1721) 2026-10-13 18:18:47 +02:00
MarekSpodBiedry 1f44a18c7a Do not journal a failed live preview at commit (#1720) 2026-10-13 18:17:49 +02:00
MarekSpodBiedry ba94976bf2 Fix scatter X axis ticks looping forever on data near the f64 maximum (#1707) 2026-10-13 18:17:33 +02:00
MarekSpodBiedry 63402451cc Check embedded PNG/JPEG bitmap size from the header before decoding (#1716) 2026-10-13 18:17:05 +02:00
MarekSpodBiedry 65cc818572 Linked files: a change during a drag is applied after the drag, not lost (#1722) 2026-10-13 18:17:02 +02:00
MarekSpodBiedry ce53bb7c57 Cap Liquify dabs while expanding a segment, not only before it (#1708) 2026-10-13 18:16:43 +02:00
MarekSpodBiedry 122fe05cee Create the staging file owner-only when replacing a document (#1712) 2026-10-13 18:16:12 +02:00
MarekSpodBiedry a5186f5a12 Keep Export for Screens suffix inside the export folder (#1713) 2026-10-13 18:16:12 +02:00
BFlat 0d0ea352df Merge pull request #1659 from storytold/fix/1597-guide-position
Fix #1597: the Transform panel shows and sets a selected ruler guide's X or Y
2026-10-13 14:05:51 +07:00
BFlat 206e81c3b7 Merge pull request #1658 from storytold/fix/1602-intersect-snap
Fix #1602: drawn points snap to where paths and guides intersect
2026-10-13 14:05:06 +07:00
BFlat 24afe0f517 Merge pull request #1608 from dotCooCoo/feat/script-checker
Scripts: Check Script… reports what a .jsx script needs and does, without running it
2026-10-13 14:03:59 +07:00
BFlat ec67ed9646 Merge pull request #1603 from switch87/feat/1601-inkscape-pages
SVG import: Inkscape's pages become artboards
2026-10-13 14:03:38 +07:00
BFlat ce558ffb73 Merge pull request #1599 from switch87/fix/1598-filter-warning
SVG import: an unsupported filter's warning names the filter and the object
2026-10-13 14:03:18 +07:00
BFlat cbe7f83881 Merge pull request #1606 from switch87/fix/1604-relative-open-path
Open by a relative path finds relative links; convert reports import warnings
2026-10-13 14:03:03 +07:00
BFlat feb319027e Merge pull request #1611 from switch87/feat/1607-more-opentype-features
Type: stylistic sets, alternates, titling, figure styles, superscript and subscript
2026-10-13 14:02:51 +07:00
BFlat 69e77570b4 Merge pull request #1657 from storytold/u/trace-logo-scans
Image Trace: Flat Logo takes the tones of an unevenly lit colour for one colour
2026-10-13 13:58:19 +07:00
BFlat 4159ffe6fd Merge pull request #1647 from storytold/u/trace-level-bound
Image Trace: levelling moves no end of a line by more than 1.6 px
2026-10-13 13:57:40 +07:00
bflatastic 51e7044b0e Fix #1597: the Transform panel shows and sets a selected ruler guide's X or Y
With only ruler guides selected, X is the position of the vertical ones and Y
that of the horizontal ones; typing a value moves them (guide.move), together
when several are selected.
2026-10-13 13:36:41 +07:00
bflatastic dae13d57f0 Fix #1602: drawn points snap to where paths and guides intersect
A point placed by the Pen, the shape tools and the other drawing tools, and a
dragged anchor, lands where two paths cross, where a path crosses a ruler guide
and where two ruler guides cross, with the Smart Guides label "intersect".
2026-10-13 13:36:41 +07:00
dotCooCoo 3374d0778d Scripts: Check Script… reports what a .jsx script needs and does, without running it
VectorCraft does not run scripts written for the reference app, and
File › Scripts held only the disabled Other Script…. File › Scripts ›
Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without
running any of it, and shows a report: what the script needs (the
script language's own objects, the document model, files, menu
commands, dialogs, or features only the reference app has), what it
would do outside the document, the menu-command strings it passes, the
document-model member chains and type names it uses, its directives,
the files it includes, and the problems that would stop it loading.
Copy Report copies the report as JSON. File › Open and drag-and-drop
show the report for those files too.

- vectorcraft-scriptcheck, a new Standalone crate with no
  dependencies, holds the checker: a lexer for ECMAScript 3 with E4X
  literals and the #name and //@name directive lines, and an analysis
  of the tokens that follows variables, function scopes and the files
  a script includes. It never recurses: open brackets, the levels of
  an XML literal and the function bodies being read are kept on stacks
  capped at 4,096. It reads at most 8 MiB of a file and 32 MiB with the
  included files, follows 64 included files 16 deep, looks up at most
  1,024 paths for them, keeps 1,000 entries in a list, tracks 1,000
  variables holding member chains and 1,000 holding File objects, and
  cuts echoed text to 120 characters. A compiled script is reported as
  compiled and isn't read.
- script.check (engine, a query) takes path, data or dataBase64. With a
  path and includes: true, it reads the files the #include lines name
  from inside the script's folder, through the automation read root,
  and when the script or one of those files assigns a member chain or a
  File object to a variable, the check treats the variable as holding
  it in every file. It doesn't follow absolute names, names, folders or
  links that lead out of the folder, names or folders longer than 4,096
  bytes, or JSINCLUDE, and it reads only regular files.
- ui.checkScript shows the report (dialog scriptCheck). File › Open
  lists Scripts (.jsx, .jsxinc, .jsxbin) and sends a script's path to
  script.check, which reads at most 8 MiB of it.

The language rules come from ECMA-262 3rd edition and ECMA-357 2nd
edition. The directive, platform and document-model root names come
from the public JavaScript Tools Guide and scripting guide pages that
catalog.rs cites by URL, and executeMenuCommand, doScript, loadAction,
unloadAction and selectTool come from the public forum posts cited
there; only names are used. Every script in the tests is written from
scratch with invented names.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-13 13:36:41 +07:00
bflatastic 04bb6d6f27 SVG import: an Inkscape page whose box can't be placed is skipped 2026-10-13 13:36:41 +07:00
Gert Pellin ef0ab7ba9d SVG import: Inkscape's pages become artboards
Inkscape 1.2+ saves a multi-page document as <inkscape:page> elements
in its <sodipodi:namedview>; VectorCraft opened it with one artboard
the size of the root, leaving the other pages' art outside any. Each
page with a size now becomes an artboard at its place (through the
viewBox, as the art is), named by its inkscape:label or "Artboard N".
Files without pages keep their one artboard.

Fixes #1601
2026-10-13 13:36:41 +07:00
Gert Pellin 546c81624a SVG import: an unsupported filter's warning names the filter and the object
A filter that can't become a live effect warned "filter on a group
ignored", even on a plain <rect>: usvg wraps every filtered element in
a group of its own, which the message named. The warning now names the
filter and its primitives and the element (by id, or "an object"), and
says it is drawn without it: "filter 'f' (feColorMatrix) on an object
isn't supported; drawn without it".

Fixes #1598
2026-10-13 13:36:40 +07:00
Gert Pellin 13bd24ba79 Open by a relative path finds relative links; convert reports import warnings
document.open (and file.newFromTemplate) with a path relative to the
working directory took the SVG's folder from Path::parent(), which is
"" for a bare file name and was then dropped, so relatively linked
images were "not found" (and, linked by their bare name, not listed in
missingLinks either). A relative path is now made absolute first: its
folder is the working directory, as for the absolute path.

vectorcraft-cli convert printed only the export's warnings; what reading
the input left out now comes as importWarnings.

Fixes #1604
2026-10-13 13:36:40 +07:00
Gert Pellin eb25a85d90 Type: stylistic sets, alternates, titling, figure styles, superscript and subscript
A character style's OpenType features took nine tags. OtFeatures now
also keeps ss01-ss20, salt, titl, pnum, lnum, sups and subs (off by
default, one bit each in `extra`): they are accepted by the type
commands, stored with the style and passed to the shaper, so a font
that has them uses them.

The Affinity import test used ss01 as its example of a feature kept but
not rendered; it now uses cv01 for that (character variants still
aren't rendered) and keeps ss01 as one that is.

Fixes #1607
2026-10-13 13:36:40 +07:00
BFlat 504e719230 Merge pull request #1650 from storytold/fix/1616-flyout-width
Fix #1616: a tool flyout is as wide as its longest translated row
2026-10-13 13:30:10 +07:00
BFlat d443cb031c Merge pull request #1649 from storytold/fix/1620-save-fallback
Part of #1620: a save falls back when a folder refuses the temporary file or the rename
2026-10-13 13:29:57 +07:00
BFlat 41afc9f3e5 Merge pull request #1614 from lewandowskit/i18n-polish-review
i18n(pl): confirm the maintainer-added Polish rows
2026-10-13 13:28:33 +07:00
BFlat 4d62e0d0ef Merge pull request #1622 from dotCooCoo/feat/lock-hide-above-linear
Object: Lock and Hide › All Artwork Above go through each top-level layer once
2026-10-13 13:28:09 +07:00
BFlat 1d1e28a51f Merge pull request #1621 from dotCooCoo/feat/pattern-tiles-per-scale
Render: pattern tiles at different scales are kept between frames
2026-10-13 13:27:53 +07:00
BFlat f870f3ac09 Merge pull request #1617 from marcoarnulfo/fix/area-type-narrow-top-lines
Area type: a word too wide for the top lines of a shape moves down instead of breaking
2026-10-13 13:27:27 +07:00
BFlat 67dcfd4fc4 Merge pull request #1619 from marcoarnulfo/fix/ai-zero-length-dots
Strokes: a zero-length subpath draws its round or projecting cap as a dot
2026-10-13 13:27:11 +07:00
BFlat 972945f80d Merge pull request #1625 from marcoarnulfo/fix/smb-save-fsync
Save: a network drive on macOS that refuses the full flush gets a plain fsync
2026-10-13 13:26:55 +07:00
bflatastic 62036eac6c Image Trace: Flat Logo takes the tones of an unevenly lit colour for one colour
A scan's paper is a tenth darker in one corner than in another. Flat Logo found
each tone of it as a colour of its own and traced the bands and the specks
between them: on the scanned inputs 470 paths, 21 px from the art on average.

Two palette colours are now one when the pixels between them are no fewer than
a third of those at the lesser of the two: flat colours leave only their shared
edge between them, tones of one colour fill the gap. Whether the image is flat
art at all is still asked of the tones as found.

Flat Logo on its inputs, all sizes and variants: 3.106 -> 0.090 px off on
average, 88.5 -> 0.82 px at most, 69.9 -> 3.9 paths; clean, aliased, blurred
and noisy art as before.
2026-10-13 13:01:01 +07:00
bflatastic c49fb30032 Fix #1616: a tool flyout is as wide as its longest translated row
A flyout's rows were 250 pt wide whatever they held, so in Spanish (and
other languages with long tool names) a name ran into its shortcut. The
rows are now as wide as the longest name and shortcut need, and never
narrower than before.
2026-10-13 12:58:05 +07:00
bflatastic 0cc7e59df0 Save: a folder that refuses the temporary file or the rename gets the content copied in (part of #1620)
A save writes a temporary file beside the document and renames it over
it. Some network shares refuse one of those steps although the document
itself can be written. The save now falls back, without giving up what
the rename is for:

- the temporary file can't be created beside the document: it is made
  in the system's temporary folder (for its owner only);
- the rename is refused: the content, whole and flushed in the
  temporary file, is copied into the document itself;
- when that copy fails, the temporary file stays and the error names
  it; when the document can't be opened for writing, it is untouched
  and the first error is returned;
- permissions that can't be copied to the temporary file (SMB, FAT) no
  longer fail the save.

A folder that isn't there and a full volume fail as before.
2026-10-13 12:58:05 +07:00
Tomasz LewandowskiandClaude Opus 5.5 26ec0464bb contributors: add my name (lewandowskit)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-13 12:58:05 +07:00
dotCooCoo f0810ca58b Object: Lock and Hide › All Artwork Above go through each top-level layer once
Lock › All Artwork Above and Hide › All Artwork Above went through the
objects above each selected object in its top-level layer, one selected
object at a time. For each object they looked up in a list whether it
was selected or already found, and they computed its bounds again for
each selected object below it until one overlapped it. With 100 tiles
selected under 25,000 rectangles, a release build of vectorcraft-cli
took 6.4 s for Lock, and with all of 5,001 objects selected it took
8.8 s and locked nothing.

The commands now go through each top-level layer that holds a selected
object once, and check each object that is not selected against the
bounds of the selected objects below it. The selected objects and the
objects found are kept in sets, and each object's bounds are computed
once. The objects they lock or hide, the count, the selection and the
undo step are the same as before. On the same documents Lock now takes
under 0.1 s.

Docs: docs/target-app-parity.md.
2026-10-13 12:58:05 +07:00
dotCooCoo 35d6aa0a16 Render: pattern tiles at different scales are kept between frames
The renderer kept one tile per pattern name and ink plane, and used it
only at the raster scale it was made at: the device scale rounded up to
a power of two and capped. Two objects that paint one pattern at scales
for which the raster scale differs, such as a pattern scaled to 25%
with Transform Pattern Tiles next to the same pattern at 100%, replaced
each other's tile, and every frame rasterized both tiles again.

Tiles are now kept per pattern name and raster scale, within the tile
cache's budget of 128 MiB per thread and ink plane. When the budget
fills, the cache drops the least recently used tiles first. A tile is
still used only while the pattern's art, tile, tile type and overlap
are the ones it was made from. At 760%, with a pattern of 576 stars in
a 288 pt tile on a rectangle that fills the view, and the same pattern
at 25% on a square, release builds of vectorcraft-cli bench at
2880x1800 took a median of 26.0 ms a frame on main and 8.8 ms with this
change, with 8 threads.

Docs: docs/target-app-parity.md.
2026-10-13 12:58:05 +07:00
Marco Arnulfo 36dc6e0c11 Area type: a word too wide for the top lines of a shape moves down instead of breaking (fixes #1615) 2026-10-13 12:58:05 +07:00
Marco Arnulfo 791bd91d5c Strokes: a zero-length subpath draws its round or projecting cap as a dot (fixes #1613) 2026-10-13 12:58:05 +07:00
Tomasz LewandowskiandClaude Opus 5.5 17314d6b61 i18n(pl): confirm the maintainer-added Polish rows
Reviewed the rows added after the Polish catalog was written (graph
legend and position options, Transform Objects, Load EPS into
Clipboard, the unknown-parameter and image-size messages, …): the
translations are correct and read naturally, so no wording changes;
drop the "not reviewed by a native speaker" marker above them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-13 12:58:05 +07:00
Marco Arnulfo 544b17ec33 Workspace: rustix is declared once in the workspace dependencies 2026-10-13 12:58:04 +07:00
Marco Arnulfo 5e8581e42f Save: a network drive on macOS that refuses the full flush gets a plain fsync (fixes #1620) 2026-10-13 12:58:04 +07:00
BFlat 63686e659e Merge pull request #1628 from storytold/docs/image-trace-1013b
Image Trace: progress docs for the edge, stroke, memory and options work
2026-10-13 12:55:05 +07:00
bflatastic 7af5790b71 Image Trace: levelling moves no end of a line by more than 1.6 px
Snap Curves To Lines made every straight piece within 2.5 degrees of level or
upright exactly so, whatever its length: a 200 px line 2 degrees off had each end
moved 3.5 px. Now a piece is levelled only when neither end moves more than
1.6 px; a longer line that far off is left as the image has it.

Technical Drawing (the preset with the option on) on its inputs: largest
distance from the art 4.21 -> 1.46 px (1.43 before levelling existed), mean
0.178 -> 0.126 px; on the askew inputs 98% -> 70% of the outline comes out level
or upright.
2026-10-13 12:40:24 +07:00
bflatastic 8a91be7983 Progress docs: Image Trace edges, strokes, memory and the Shapes, Auto Grouping and Gradients options 2026-10-13 12:25:31 +07:00
BFlat 5bfe774420 Merge pull request #1627 from storytold/u/trace-gradients
Image Trace: Gradients
2026-10-13 12:24:35 +07:00
BFlat e918615347 Merge pull request #1623 from storytold/fix/1592-effect-params
Effects: effect.apply and effect.setParams refuse parameters the effect doesn't take (#1592)
2026-10-13 12:19:00 +07:00
bflatastic 250da4226e Image Trace: Gradients
Gradients (params `gradients`, a checkbox in the panel for Grayscale and Color;
off unless asked for): an area whose colour changes evenly along one direction
is traced as one path filled with a linear gradient, not as bands of flat colour.

Gradients are found before the palette is made, so no colour is spent on their
bands: a connected run of smoothly changing pixels is a gradient when a plane
fits each colour channel with all three sloping the same way, the colours along
that way are at most six stops with ramps between them, and painting them back
gives the area's own pixels. Flat colour, soft edges between flat colours and
shading that goes two ways are left as they were.

The harness gains two linear-gradient inputs with known lines and stops, and
measures whether each is found, the angle of its line and the colours of its ramp.
2026-10-13 12:16:03 +07:00
BFlat 17b4396a97 Merge pull request #1585 from p0lyg0n/fix/pdf-glyph-by-glyph-strokes
PDF/.ai import: type stroked glyph by glyph stays one object
2026-10-13 12:07:21 +07:00
BFlat 5adeada26d Merge pull request #1586 from p0lyg0n/fix/pdf-type-on-a-path-tracking
PDF/.ai import: evenly tracked type on a path keeps its length
2026-10-13 12:07:05 +07:00
bflatastic 175ad4a1e3 Effects: effect.apply and effect.setParams refuse parameters the effect doesn't take (#1592) 2026-10-13 11:53:38 +07:00
bflatastic 95304ad7f6 PDF import: a held glyph stroke takes its turn in the page's overprint list 2026-10-13 11:53:38 +07:00
Hiroshi InukaiandClaude Opus 5.5 02da9bbdc9 PDF/.ai import: evenly tracked type on a path keeps its length
Straight type takes the tracking that keeps its length as the file set
it; type on a path took none, so letter-spaced type on a curve came back
closed up to the font's advances (an "OFFICIAL ISSUED" badge sat to one
side of its band, a condensed title ran off the end of its path). Type on
a path now takes the same tracking, its length measured along the path
from the first glyph to the end of the last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:53:38 +07:00
Hiroshi Inukai 331fc48b24 PDF/.ai import: type stroked glyph by glyph stays one object
Type on a path is written glyph by glyph, and a stroke added to it as each
glyph's outline stroked right after the glyph. The path in between ended the
line, so each glyph came in as its own text object. Such a stroke, in the
glyph's own paint, is now held with the line and written after it, where it
becomes the type's stroke as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/pdf/src/tests_import_lines.rs
2026-10-13 11:53:38 +07:00
BFlat 8d2d838884 Merge pull request #1618 from storytold/u/trace-groups
Image Trace: Auto Grouping
2026-10-13 11:47:48 +07:00
BFlat bc5bdf47d1 Merge pull request #1591 from switch87/fix/1590-offset-path-message
Offset Path: an empty result says the offset leaves nothing
2026-10-13 11:29:17 +07:00
BFlat 6b99150aac Merge pull request #1589 from switch87/fix/1584-icc-tag-alignment
ICC profiles written here have their tags 4-byte aligned
2026-10-13 11:28:57 +07:00
BFlat 26bb452ba1 Merge pull request #1576 from excited-raccoon/palette-nearest-spatial-index
Palette nearest-colour lookup: branch-and-bound index over the linear scan
2026-10-13 11:28:37 +07:00
BFlat dbb8bdcaaa Merge pull request #1571 from excited-raccoon/select-behind-linear
Select Behind: test each object once instead of re-hitting the whole document
2026-10-13 11:28:08 +07:00
BFlat ecf4ae7a3a Merge pull request #1577 from switch87/fix/ids-without-selection
Commands: objects given as ids stand in for the selection
2026-10-13 11:27:30 +07:00
BFlat e518974913 Merge pull request #1581 from switch87/fix/1578-arc-warp-aspect
Warp › Arc: bend in the object's real proportions
2026-10-13 11:27:17 +07:00
BFlat a20b0308b8 Merge pull request #1569 from switch87/feat/svg-flowroot-area-type
SVG import: Inkscape flowed text (flowRoot) becomes area type
2026-10-13 11:26:57 +07:00
BFlat 3126e82ebd Merge pull request #1572 from switch87/fix/1570-pathfinder-strokes
Pathfinder: Trim, Merge and Crop remove strokes
2026-10-13 11:26:39 +07:00
BFlat d8a298a310 Merge pull request #1612 from storytold/docs/progress-1013c
Docs: ROADMAP 2026-10-13 adds #1492–#1583; Average and Join corrected to missing, Polish row count
2026-10-13 11:26:05 +07:00
bflatastic 9c42a2942a Image Trace: Auto Grouping
Auto Grouping (params `autoGrouping`, a checkbox in the panel; off unless asked
for): each traced path that has others inside its outer contour comes out in a
group with them, the path first, nested as deep as the picture is. The path that
covers the whole image is nobody's group.

The grouped result paints the same picture: where paths can overlap (stacked
layers, Flat Logo, strokes) a path only joins a path below it, and only if that
moves it under nothing that shares any of its box.

The harness measures three presets with the option on ("grouped %"), painting
them in the grouped order.
2026-10-13 11:15:02 +07:00
BFlat 36286cc783 Merge pull request #1609 from storytold/u/trace-shapes
Image Trace: Shapes, and corners whose pixel is mostly outside the shape
2026-10-13 11:02:42 +07:00
bflatastic bc343414d8 Offset Path: the new message in the catalogs that translate messages 2026-10-13 10:52:38 +07:00
Gert Pellin 7b0164e4e7 ICC profiles written here have their tags 4-byte aligned
moxcms's encode packs tagged elements back to back, so the built-in
sRGB profile embedded in JPEG, TIFF and PSD exports was 634 bytes with
`cprt` at offset 558, where ICC.1 §7.1.2 requires every tagged element
on a 4-byte boundary (stricter readers reject the files). encode now
lays the tag data out again with each element padded, updating the tag
offsets, the size and the profile ID (zeroed, "not computed"); tags
that share data keep sharing it. Reported to moxcms as
awxkee/moxcms#198.

Fixes #1584
2026-10-13 10:52:38 +07:00
bflatastic 670946ef75 Palette index: rustfmt 2026-10-13 10:52:38 +07:00
bflatastic bdb9963c73 Select Behind: one test of what may be hit for both walks, and random documents checked against the repeated hit test 2026-10-13 10:52:38 +07:00
bflatastic dc64aaa41b Commands: ids stand in for the selection where the params description names the key, checked over every command 2026-10-13 10:52:38 +07:00
Gert Pellin 5366cf689e Offset Path: an empty result says the offset leaves nothing
With paths selected but every offset empty (an inset larger than the
shape), Offset Path failed with "select paths", sending the user to
look for a selection problem. It now says the offset leaves nothing of
the selected paths; "select paths" stays for selections without paths.

Fixes #1590
2026-10-13 10:52:38 +07:00
Ayanprogrammer11 cca3a63e4a Palette nearest-colour lookup: branch-and-bound index over the linear scan
Indexed export (GIF, PNG-8, BMP, TIFF) finds each pixel's nearest
palette entry with a linear scan, and the k-means refinement in median
cut does the same once per cell per pass. With the 256-entry palettes
this code produces, that is 256 distance evaluations per query.

PaletteIndex is an exact nearest-neighbour kd-tree over the palette,
balanced at the widest weighted axis, with tight channel bounds in every
subtree so branch-and-bound can skip whole boxes. Small palettes (12 or
fewer entries) keep the scan, which is cheaper than tree traversal.

Results match the linear scan exactly, including ties: an equal-distance
entry with a smaller index still wins. The bound uses the same weighted
squared f32 form as dist() rather than a transformed space, so rounding
cannot change which entry is chosen.

Three tests: randomized parity against the original scan across palette
sizes spanning the cutoff, three weight vectors, duplicate colours and
fractional query points; explicit tie-breaking cases; and an opt-in
benchmark comparing 100k queries at both, behind -- --ignored.
2026-10-13 10:52:38 +07:00
Ayanprogrammer11 507c6e0e63 Select Behind: test each object once instead of re-hitting the whole document
objects_at() found Select Behind targets by running hit_test_skipping()
in a loop, skipping everything found so far. Each round is a full tree
traversal, so a stack of n overlapping paths costs O(n^2) hit tests
before the loop finds no further object.

The fast path walks each editable top-level layer once. Every selectable
object's subtree is visited only until its first hit, and a HashSet
records found ids so subtrees already covered by an earlier hit are not
searched again. Sublayers recurse the same way, so a stack of n paths
costs one traversal total.

The original traversal stays in charge where Select Behind semantics
differ from the plain front-to-back order: isolation (scope), and Edit
Contents, which exposes objects nested inside a top-level group. Non-
layer roots in malformed files also fall back. objects_at_repeated()
keeps the old loop for those cases.

Three tests: a 256-path stack asserts front-to-back order and
completeness; a mixed document (sublayer, groups, clipping group,
outline and path-only options) asserts the one-pass result equals the
repeated-traversal result; an envelope in Edit Contents asserts the
fallback preserves the original order.
2026-10-13 10:52:38 +07:00
Gert Pellin dfc1b846cc Commands: objects given as ids stand in for the selection
Commands whose description takes `ids` (operate on these objects;
default: the selection) failed with "nothing selected" when objects
were given but none was selected: execute checked their `enabled`
predicate against the selection, with an exception for edit.clear only.
Of the 220 commands mentioning ids, 48 were refused so; their run
already used the ids. execute now evaluates `enabled` with the given
ids selected and puts the selection back, so each predicate keeps its
meaning (Make with Top Object still needs two objects).

Fixes #1575
2026-10-13 10:52:38 +07:00
Gert Pellin a169693817 Warp › Arc: bend in the object's real proportions
warp_point worked on box coordinates normalised to -1..1 on both axes:
Arc's radius was in x units but its edges were offset in y units, so a
box that isn't square bent into no circular arc (skewed sides from
Bend 10 %), and at 100 % the radius (2/π) was smaller than the
half-height, folding the inner edge over. warp_point now takes the
box's aspect along the warp; Arc keeps the centre line's length on a
circular arc in real proportions, with concentric edges, and an inner
edge that would pass the centre stops there. Other styles are as they
were; a square is bent as before.

Fixes #1578
2026-10-13 10:52:37 +07:00
bflatastic db85d465c8 SVG import: flowed text with a region too large to place is left out with a warning 2026-10-13 10:52:37 +07:00
Gert Pellin de8d5c96ab Pathfinder: Trim, Merge and Crop remove strokes
The public Pathfinder documentation says Trim and Crop remove any
strokes, and Merge removes them and merges same-coloured fills. The
pieces kept every stroke, and Merge, grouping by the whole style, left
same-filled objects with different strokes apart. The three now drop
stroke layers before styles are compared.

Fixes #1570
2026-10-13 10:52:37 +07:00
bflatastic d44615753a Docs: ROADMAP 2026-10-13 adds #1492–#1583; Average and Join corrected to missing, Polish row count 2026-10-13 10:52:37 +07:00
Gert Pellin e794c1ac63 SVG import: Inkscape flowed text (flowRoot) becomes area type
<flowRoot> (SVG 1.2 flowed text, which Inkscape writes for text typed
into a frame) was dropped without a word: usvg doesn't draw it. The
text pass now reads it as area type: the first <rect> of its
<flowRegion> is the frame (with the rect's transform), each <flowPara>
a paragraph (white space collapsed), styled through the same cascade
as <text>. Another kind of region leaves the text out with a warning.

Fixes #1568
2026-10-13 10:52:37 +07:00
BFlat 0573d3cdfb Merge pull request #1596 from storytold/fix/1561-symbol-percentages
SVG import: percentage lengths in a symbol with a viewBox are of the viewBox
2026-10-13 10:26:19 +07:00
bflatastic 9ca74ac011 Image Trace: Shapes, and corners whose pixel is mostly outside the shape
Shapes (params `shapes`, a checkbox in the panel; off unless asked for): a filled
outline that is a circle, an ellipse, a rectangle or a regular polygon comes out
as that live shape. A shape is only made when every point of the traced outline
lies within 0.5 px (0.2% of the shape's size, if more) of it; an ellipse whose
radii differ by less than twice that is a circle of its area.

The outline before fitting: a corner's own pixel no longer pulls the two edges
that meet there, and a one-pixel step between two long edges is the corner where
they meet. Sharp corners kept: Default 69% -> 76%, the colour presets 86% -> 91%.

The harness gains plain-shape inputs and "shapes %".
2026-10-13 10:25:05 +07:00
BFlat f0f3cb4eb1 Merge pull request #1595 from storytold/u/trace-strokes
Image Trace: stroked centre lines lie in the middle of their ink and are as wide as it
2026-10-13 10:14:01 +07:00
bflatastic 5fe916a425 SVG import: percentage lengths in a symbol with a viewBox are of the viewBox (#1561) 2026-10-13 09:58:14 +07:00
bflatastic 8e7b9ba436 Image Trace: stroked centre lines lie in the middle of their ink and are as wide as it
Create: Strokes (the Outline preset, or the option) traced lines along the pixel skeleton: right to a pixel,
with a width counted in pixels, and fitted by a separate, older fitter.

- Each skeleton point is moved to the middle of the line's ink, read from the image across the line: every
  pixel counts as much ink as its colour is the line's rather than the paper's, the ink's centroid is the
  middle and its sum the width (an anti-aliased or blurred line says both to a fraction of a pixel).
- The stroke width is the median of those widths, no longer a whole number of pixels.
- Each end goes where the ink ends, less the half width a round cap adds.
- Centre lines are fitted as outlines are: a straight line is two anchors, corners stay corners, Snap Curves To
  Lines levels them. The old Douglas-Peucker and simplify fitter, and the crate's pathops dependency, go.

Outline on the thin-line inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- painted outline 0.310 -> 0.055 px off on average; 81 -> 14 anchors for five lines
- centre line 0.295 -> 0.052 px from the true centre line; stroke width 0.13 -> 0.02 px off; 18.4 -> 2.7
  anchors a stroke (blurred, noisy and scanned lines: 2.0 each; aliased: 4.6)
- largest distance 1.84 -> 2.94 px: the square ends of a 10 px line under a round cap
Presets without Create: Strokes are unchanged.
2026-10-13 09:36:47 +07:00
BFlat cbcee3bd54 Merge pull request #1593 from storytold/u/trace-upright
Image Trace: Snap Curves To Lines levels lines a degree or two askew
2026-10-13 09:36:14 +07:00
BFlat 0810a15f6e Merge pull request #1566 from marcoarnulfo/fix/new-symbol-options-dialog
Symbols panel: New Symbol asks for Symbol Options before making the symbol
2026-10-13 09:31:54 +07:00
BFlat db7e568809 Merge pull request #1567 from excited-raccoon/css-class-dedup-hashmap
Replace linear scan in CSS class dedup with a HashMap lookup
2026-10-13 09:31:36 +07:00
BFlat 9d72686d95 Merge pull request #1565 from switch87/fix/1557-css-transform-font
SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type
2026-10-13 09:31:20 +07:00
BFlat d79fb145c2 Merge pull request #1558 from marcoarnulfo/fix/zigzag-regular-waveform
Zig Zag: ridges are spaced evenly along each segment's length, so a straight line gets a regular wave
2026-10-13 09:31:04 +07:00
BFlat 6a16627de6 Merge pull request #1583 from storytold/fix/1534-guides
Fix #1534: guide paths take the guide colour and style, and a dragged selection snaps to ruler guides with Smart Guides on
2026-10-13 09:09:09 +07:00
BFlat acc1c9a85d Merge pull request #1582 from storytold/fix/1533-join-coincident
Fix #1533: Join merges the selected anchors that lie on one another (after Average) into one
2026-10-13 09:08:58 +07:00
BFlat 8fbf724cf6 Merge pull request #1580 from storytold/fix/1539-shortcut-menuless
Keyboard Shortcuts: the commands outside the menus that run without params are listed too (#1539)
2026-10-13 09:08:47 +07:00
BFlat 02ed637c68 Merge pull request #1544 from switch87/fix/1542-clip-covers-page
PDF/EPS import: a clip only counts as covering the page when it does
2026-10-13 09:08:21 +07:00
BFlat 9d2b074d12 Merge pull request #1543 from marcoarnulfo/fix/guide-objects-locked
Guides: Lock Guides locks the paths made into guides too
2026-10-13 09:08:05 +07:00
BFlat 632ea2a9f3 Merge pull request #1541 from switch87/fix/1536-effect-raster-paint-transform
Render: effect rasters drawn after a pattern fill keep their place and size
2026-10-13 09:07:48 +07:00
BFlat 523fd6e185 Merge pull request #1549 from marcoarnulfo/fix/duplicate-shortcut
Keyboard Shortcuts: Duplicate is listed, so it can be given a shortcut
2026-10-13 09:07:31 +07:00
bflatastic 612082e26e Image Trace: Snap Curves To Lines levels lines a degree or two askew
The documented option "replaces slightly curved lines with straight lines and snaps lines near 0 or 90 degrees
to absolute 0 or 90 degrees" (helpx, Image Trace panel options); ours did the first half only.

- With Snap Curves To Lines, a straight piece at least 2 px long and within 2.5 degrees of level or upright is
  made exactly so, by moving its ends across it to their mean (the page gives no angle: 2.5 degrees is our
  choice). The pieces next to a moved end follow it.
- A junction other regions share stays where it is, so abutting regions still have one curve along their edge;
  a line between a junction and a free end is levelled to the junction.
- The harness gains askew inputs (a rectangle, a plus sign and two lines, 1 to 2 degrees off level and upright)
  and an "upright %" column: the share of the outline length that is exactly level or upright.

On the clean askew inputs (docs/image-trace-quality.md): Black and White Logo and Technical Drawing, the presets
with the option on, go from 0% to 91% and 95% of their outline exactly level or upright. Their outlines are then
0.49 and 0.59 px from the art as drawn where they were 0.05 (4.2 px at the far end of a 300 px side turned 1.5
degrees): that distance is what the option is for. Presets without the option are unchanged.
2026-10-13 09:07:13 +07:00
bflatastic fea316911d Symbols panel: Alt-click on New Symbol skips Symbol Options 2026-10-13 08:53:06 +07:00
Ayanprogrammer11 3af08505f8 Replace linear scan in CSS class dedup with a HashMap lookup
Export::class() deduplicated declaration blocks with
classes.iter().position(), an O(n) scan per style, making exports with
many distinct styles (e.g. large palettes) quadratic overall. Track the
1-based class number per declaration in a HashMap alongside the
first-use-ordered Vec, so each lookup is O(1) while output stays
byte-identical.

Also adds a regression test covering 256 distinct declarations with
recurrence of early styles, for both InternalCss and StyleEntities
styling.
2026-10-13 08:53:06 +07:00
Gert Pellin 2c01a42b35 SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type
usvg takes a `transform` CSS property as a presentation attribute that
overrides the element's own, but reads its value as SVG: CSS units
(`translate(100px, 0)`, `rotate(45deg)`) don't parse, and the object
was left with no transform at all. Before usvg runs, transform
declarations in `style` attributes and `<style>` rules are now written
in SVG syntax (lengths in px, angles in deg/rad/grad/turn, translateX/Y,
scaleX/Y and skew as SVG's functions); values that can't be are left.

The text pass's own cascade now expands the `font` shorthand into its
longhands, as usvg does for the rest of the drawing, so text styled
with `font: italic bold 30px …` keeps its size, weight and style.

Refs #1557
2026-10-13 08:53:06 +07:00
bflatastic 45334df881 Zig Zag: tests of the ridge spacing on a circle and on an uneven curve 2026-10-13 08:53:06 +07:00
Marco Arnulfo ee97225c19 Symbols panel: New Symbol asks for Symbol Options before making the symbol (fixes #1560) 2026-10-13 08:53:06 +07:00
Marco Arnulfo 6085197bf4 Zig Zag: ridges are spaced evenly along each segment's length, so a straight line gets a regular wave (fixes #1554) 2026-10-13 08:53:05 +07:00
bflatastic 50911d8608 Fix #1534: a dragged selection snaps to ruler guides with Smart Guides on too 2026-10-13 08:51:43 +07:00
bflatastic 6939eff2f9 Fix #1534: paths made into guides take the guide colour and style of Preferences 2026-10-13 08:51:43 +07:00
Gert Pellin 9652d1d6b2 PDF/EPS import: a clip only counts as covering the page when it does
Both readers skipped a clip of at most six straight segments whose
bounding box contained the page. A diamond touching the page edges has
the page as its bounding box, so its clip was dropped and everything it
cuts away showed. geom::hit::polygon_covers_rect answers it properly
(one convex polygon containing the page's corners); any other path is
kept as a clip.

Fixes #1542
2026-10-13 08:51:40 +07:00
Marco Arnulfo 79b5d73ca7 Guides: Lock Guides locks the paths made into guides too (part of #1534) 2026-10-13 08:51:38 +07:00
Gert Pellin 05fdd15b45 Render: effect rasters drawn after a pattern fill keep their place and size
fx.rs draws blur, shadow and glow rasters as image paints that map 1:1
onto their rect, but only set the geometry transform: the paint
transform a pattern fill (or a radial or freeform gradient) left on the
context still applied, so in supersampled renders (raster exports) the
effect came out tiny and displaced. Both draws now reset the paint
transform first.

Fixes #1536
2026-10-13 08:51:37 +07:00
bflatastic 0a16b62a22 Fix #1533: Join merges the selected anchors that lie on one another (after Average) into one 2026-10-13 08:51:34 +07:00
bflatastic 20f5174710 Keyboard Shortcuts: the commands outside the menus that run without params are listed too (#1539) 2026-10-13 08:51:32 +07:00
Marco Arnulfo 00195e8d28 Keyboard Shortcuts: Duplicate is listed, so it can be given a shortcut (fixes #1539) 2026-10-13 08:51:30 +07:00
BFlat 05e321f4e0 Merge pull request #1574 from storytold/u/trace-options
Image Trace: Ignore Color, and the Auto Color and Outline presets
2026-10-13 08:47:04 +07:00
BFlat 85770d3afb Merge pull request #1553 from switch87/fix/1552-type42-em
EPS/PS import: Type 42 fonts set type at their size
2026-10-13 08:27:05 +07:00
BFlat 421443b8ac Merge pull request #1551 from switch87/fix/1550-pdf-alpha-softmask-images
PDF import: images in an alpha soft mask count by their alpha
2026-10-13 08:26:49 +07:00
Gert Pellin e2a63a8baf EPS/PS import: Type 42 fonts set type at their size
Interp::font took a font's FontMatrix times 1000 as the matrix of one
em, the Type 1 convention (glyphs in 1000-unit space). A Type 42 font's
glyph space is one unit an em, its FontMatrix usually the identity, so
type in Type 42 fonts (cairo, Inkscape, LibreOffice) came in 1000 times
too large and off the page. FontType 42 now takes the matrix as it is.

Fixes #1552
2026-10-13 08:11:27 +07:00
bflatastic 44d170f059 PDF import: pattern swatches read inside an alpha mask keep their images; a luminosity mask test 2026-10-13 08:11:27 +07:00
Gert Pellin fce90e3144 PDF import: images in an alpha soft mask count by their alpha
An alpha soft mask's vector art is whitened so its luminance is its
alpha, but images kept their colour: cairo (and so Inkscape) draws a
mask as an image whose alpha is in its own SMask, often black pixels,
so the mask came in empty. While reading an alpha mask's art the
importer now reads images white with their own alpha (under their own
cache key, without the JPEG and CMYK shortcuts), and stencil masks
white; a luminosity mask nested inside reads them as they are.

Fixes #1550
2026-10-13 08:11:27 +07:00
bflatastic 761cc2371a Image Trace: Ignore Color, and the Auto Color and Outline presets
Against the documented panel (https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html):

- Ignore Color: "Ignores a specific color while tracing. Use the color picker to selectively ignore a color."
  The panel's Ignore White is now Ignore Color with a colour button beside it (white until another colour is
  picked); `imageTrace.make` takes `ignoreColor: "#rrggbb"` beside `ignoreWhite`, and leaves out the palette
  colours within about 30 levels of it. Documents saved before have no such setting and ignore white as they
  did. Flat Logo mode leaves out white only.
- Auto Color ("Creates a posterized image from a photo or artwork"): Color mode with the Automatic palette.
- Outline ("Simplifies the image to black outlines"): Black and White with Create: Strokes and no fills.
- The Outline preset's name is translated as a preset's name, not as the Outline view's.

Measured (docs/image-trace-quality.md): Auto Color on flat art is 0.140 px off on average with 4.0 paths (16
Colors: 0.141, 4.2). Outline on the line inputs, the first measure of Create: Strokes: 0.310 px off on average,
1.84 at worst, 81 anchors for 5 lines (Line Art's filled outlines: 0.055, 0.96, 46).
2026-10-13 08:04:32 +07:00
BFlat 3752b3ec31 Merge pull request #1563 from storytold/u/trace-thin
Image Trace: thin lines in blurred and scanned art keep smooth edges
2026-10-13 07:50:58 +07:00
bflatastic c2ed31dc4c Image Trace: thin lines in blurred and scanned art keep smooth edges
Black and White read every pixel as a blend of "black" and "white", and took for those the mean luminance of
the image's black and white pixels. In blurred or scanned line art most black pixels are part paper, so the
mean black was a gray: the middle of every line counted as more than all ink, and by how much changed as the
line crossed the pixel rows, which put a ripple on its edges that the fit then followed with anchors.

- Black is the image's darkest black (the 2nd percentile of its black pixels' luminance).
- White is the lightest pixel within a few pixels of each crack: the paper as it is there. A scan's paper is
  darker in one corner than in another, and read against the image's lightest white it counted as part ink.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- Line Art: 78 -> 46 anchors, outlines 0.093 -> 0.055 px off on average (blurred lines 168 -> 53 anchors,
  scanned 199 -> 70); before sub-pixel edges it was 25 anchors and 0.158 px off
- Technical Drawing: 56 -> 46 anchors, 0.092 -> 0.084 px off
- Sketched Art: 0.197 -> 0.186 px off; Default 0.092 -> 0.090; Black and White Logo 0.063 -> 0.059 with
  27 -> 29 anchors
Not kept: reading lines up to six pixels deep by their ink's centroid and width, and a looser tolerance for
straight runs, neither of which measured better.
2026-10-13 07:27:56 +07:00
BFlat d9e44350d2 Merge pull request #1548 from storytold/fix/1529-fontconfig-dirs
Fix #1529: Linux reads the font folders fontconfig is set up with
2026-10-13 07:14:31 +07:00
BFlat 0f4de5b98e Merge pull request #1547 from storytold/fix/1512-corners-smooth
Fix #1512: round corners next to curves stay inside the corner, and the Smooth tool smooths what is under the drag
2026-10-13 07:14:14 +07:00
BFlat a971b5b70e Merge pull request #1546 from storytold/u/trace-memory
Image Trace: a 16 megapixel photo is traced in 0.9 GB, not 3.9
2026-10-13 07:14:01 +07:00
BFlat 6beaa89be2 Merge pull request #1531 from switch87/fix/1527-textpath-basic-shapes
SVG import: <textPath> follows basic shapes (circle, ellipse, rect, line, polyline, polygon)
2026-10-13 07:13:45 +07:00
BFlat 40b02f479b Merge pull request #1530 from EricKuck/fix/ai-gradient-stop-order
AI import: gradient stops listed upwards aren't reversed, so a midpoint stays on the stop that starts its part of the ramp
2026-10-13 07:13:29 +07:00
BFlat afda1cd846 Merge pull request #1528 from fortesnow/ai-off-page-art-overlap
.ai import: a page's art past its artboard no longer lies over the page before it
2026-10-13 07:13:12 +07:00
BFlat a7c6644691 Merge pull request #1545 from storytold/docs/progress-1013b
Docs: ROADMAP 2026-10-12 adds #1474–#1525 (late merges of the day)
2026-10-13 07:12:46 +07:00
bflatastic 3a88ad0855 Fix #1529: Linux reads the font folders fontconfig is set up with
The font scan read /usr/share/fonts, /usr/local/share/fonts, ~/.fonts and the
XDG data folders. Packages add folders outside those to fontconfig's
configuration (TeX Gyre under /usr/share/texmf/fonts), so fc-list and other
apps showed fonts VectorCraft didn't have. The scan now also reads the <dir>
folders of /etc/fonts/fonts.conf and the files it includes (conf.d, the user's
own), as the fontconfig documentation describes them. The files are read as
they are: no library is linked and no program is run.
2026-10-13 06:56:37 +07:00
bflatastic 61cc3f6040 Fix #1512: the Smooth tool smooths the stretch of path under the drag
A drag near an anchor refitted the whole segments on either side of it, so a
short drag over a small bump on a long side turned the side into a curve from
end to end. The segments leading into the anchors under the drag are now split
where they enter the tool's reach, and only what is in reach is refitted.
2026-10-13 06:56:37 +07:00
Gert Pellin 3239e8b97e SVG import: <textPath> follows basic shapes (circle, ellipse, rect, line, polyline, polygon)
A <textPath href="#id"> pointing at a basic shape imported as point type
with "textPath has no usable path", as only <path> was followed. SVG 2
allows any basic shape, and Inkscape writes one whenever text is put on
a circle. The shape now becomes its equivalent path as SVG 2 defines
it: circles and ellipses start at 3 o'clock and rounded rects after the
top-left corner, all clockwise; lines, polylines and polygons follow
their points. The shape's transform applies as for a <path>. Shapes
that render nothing (zero radius or size) still fall back to point
type with the warning.

Fixes #1527
2026-10-13 06:56:37 +07:00
EricKuckandClaude Opus 5.5 48d5bd1aee AI import: gradient stops listed upwards aren't reversed, so a midpoint stays on the stop that starts its part of the ramp
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 06:56:37 +07:00
bflatastic 5203f4f047 PDF import: the type of a .ai page moved right keeps the stroke drawn on its glyphs
The ink boxes of the page's lines of type move with its art, and art at a
coordinate that isn't a number moves nothing.
2026-10-13 06:56:37 +07:00
bflatastic a072e29016 Fix #1512: a round corner next to a curved side no longer sticks out of the corner
Live Corners cut a corner between a curve and another side with the handles of
the arc between straight sides. The curve has turned by the time the cut meets
it, so those handles reached past where the tangents at the cut's ends cross,
and from a middling radius on the cut bulged out of the shape or looped. Each
handle is now the share an arc takes of its tangent up to that crossing.
Corners between straight sides are cut as before.
2026-10-13 06:56:37 +07:00
fortesnow ba68fcfbfc .ai import: a page's art past its artboard no longer lies over the page before it
A PDF-compatible .ai keeps its art past the artboard edge unclipped (#646),
and its pages are laid out in a row 36 pt apart. Art that a page draws far
to the left of its artboard (notes beside a design, say) then lay over the
page before it. There it was taken for that page's art: the check against
the editing data found art its layers don't have and opened the file as
its PDF part, without the art outside its artboards, and place_pages left
that art behind when it moved the pages.

Such a page now moves right until its art clears the art before it.
Plain PDFs (clipped to the page) keep the 36 pt gap.
2026-10-13 06:56:37 +07:00
bflatastic c9fac95760 Image Trace: a 16 megapixel photo is traced in 0.9 GB, not 3.9
Every colour layer took a mask, component labels, a fill stack and an edge map the size of the whole image,
and as many layers were traced at once as the machine has cores.

- A layer is traced within the box its pixels lie in.
- The stack that fills components holds `u32` indices (a mask has at most 64 million pixels).
- Layers are traced as many at a time as fit in half a gigabyte (about ten bytes a pixel of the largest
  layer's box each), on however many cores.

Peak memory above the image itself, photo-like image (docs/image-trace-quality.md), before -> after:
- 16 megapixels: High Fidelity Photo 3,856 -> 880 MB (2.85 -> 3.65 s), Low Fidelity Photo 1,972 -> 754 MB
  (1.49 -> 1.80 s), 16 Colors 1,625 -> 298 MB, Shades of Gray 852 -> 407 MB, Default 256 -> 199 MB
- 4 megapixels: High Fidelity Photo 981 -> 643 MB, 16 Colors 408 -> 122 MB, times unchanged
The traces are the same, path for path.
2026-10-13 06:38:10 +07:00
648 changed files with 111020 additions and 19927 deletions

No files matched your search

+21 -43
View File
@@ -1,19 +1,25 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
workflow_dispatch:
concurrency:
@@ -25,39 +31,11 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
# The checkout above is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
package:
name: FreeBSD 14 (package)
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+4 -45
View File
@@ -19,48 +19,7 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.vectorcraft" and m["command"] == "vectorcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
+23 -488
View File
@@ -1,11 +1,5 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "VectorCraft v<version>". See docs/releasing.md.
#
# Jobs that sign (macOS, Windows) and the job that creates the release run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# other jobs sign nothing and get no environment, so a `workflow_dispatch` on any branch is a dry
# run: they build and upload artifacts, the signed jobs are refused, and no release is drafted.
name: Release
on:
@@ -19,492 +13,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
VECTORCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets here.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: Linux packages are not signed (checksums in SHA256SUMS.txt).
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/vectorcraft$|metainfo|applications'
ldd target/release/vectorcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
# No APPIMAGE_EXTRACT_AND_RUN here: with it the runtime passes --appimage-* flags on
# to the app instead of answering them itself.
"$appimage" --appimage-updateinformation \
| grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image and packages as
# freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed through by name; never templated into the script.
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets here.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
# The riscv64 variants of the linux job's -dev packages.
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
desktop-file-utils appstream
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the package
run: |
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="VectorCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+24 -81
View File
@@ -1,22 +1,27 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
# Windows ARM64 build and smoke test.
#
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
pull_request:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
workflow_dispatch:
concurrency:
@@ -26,72 +31,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: vectorcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'vectorcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: vectorcraft
app-dir: VectorCraft
+31
View File
@@ -117,6 +117,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dimension.svg` | Osama Amin | Original work, drawn for VectorCraft: a dimension line with arrowheads between two extension lines | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -133,6 +134,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-draw-normal.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-ellipse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-fill-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-filter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-flare.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-folder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-free-transform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -323,6 +325,11 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-6-revolve.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/shot-7-dimensions.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/bracket-drawing.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-still-life.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/still-life.vectorcraft` | MIT OR Apache-2.0 | |
| `examples/still-life.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/bracket-drawing.vectorcraft` | VectorCraft contributors | Original drawing built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
@@ -333,3 +340,27 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
## Assets embedded by the PDF backend
The shared PDF backend keeps the existing standard-font and CMap features. These files remain in dependency sources; no font files are copied into this repository. Hashes identify the pinned dependency contents.
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
Generated
+84 -52
View File
@@ -806,6 +806,17 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"hayro-interpret",
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1372,24 +1383,13 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree 0.20.0",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
]
@@ -1777,8 +1777,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -1788,7 +1787,7 @@ dependencies = [
"phf",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"skrifa 0.47.0",
"smallvec",
"yoke",
]
@@ -1796,8 +1795,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -1821,8 +1819,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -1831,6 +1828,7 @@ dependencies = [
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -2221,8 +2219,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3640,9 +3637,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
@@ -3995,6 +3992,17 @@ dependencies = [
"serde",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -4379,6 +4387,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -4490,7 +4507,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4510,6 +4527,7 @@ dependencies = [
"raw-window-handle",
"rfd",
"serde_json",
"socket2",
"vectorcraft-engine",
"vectorcraft-text",
"vectorcraft-ui-egui",
@@ -4524,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4535,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4547,7 +4565,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4563,7 +4581,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4585,7 +4603,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4597,7 +4615,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4614,8 +4632,9 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde_json",
"vectorcraft-color",
@@ -4629,7 +4648,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4654,6 +4673,7 @@ dependencies = [
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-scriptcheck",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
@@ -4664,7 +4684,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"image",
@@ -4685,10 +4705,11 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"proptest",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4699,7 +4720,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4711,7 +4732,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"log",
@@ -4730,7 +4751,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4744,7 +4765,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4756,18 +4777,18 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"aes",
"craft-pdf",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",
"jpeg-encoder",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
"roxmltree 0.21.1",
"serde",
"serde_json",
"sha2",
@@ -4781,11 +4802,12 @@ dependencies = [
"vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text",
"weezl",
]
[[package]]
name = "vectorcraft-plugins"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde",
"serde_json",
@@ -4799,7 +4821,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"gif",
@@ -4815,16 +4837,25 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-plugins",
"vectorcraft-testkit",
"vectorcraft-text",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"wat",
"weezl",
]
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.11.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"flate2",
"image",
@@ -4848,7 +4879,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4865,7 +4896,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4885,7 +4916,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4893,7 +4924,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4901,6 +4932,7 @@ dependencies = [
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-text",
@@ -4908,7 +4940,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4916,12 +4948,11 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"vectorcraft-geom",
"vectorcraft-pathops",
]
[[package]]
name = "vectorcraft-ui-egui"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"craft-ui",
"egui",
@@ -4950,11 +4981,12 @@ dependencies = [
"vectorcraft-three-d",
"vectorcraft-tools",
"wat",
"web-time",
]
[[package]]
name = "vectorcraft-web"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -6064,7 +6096,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde_json",
]
+24 -6
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
@@ -20,7 +21,9 @@ vectorcraft-doc = { path = "crates/doc" }
vectorcraft-pathops = { path = "crates/pathops" }
vectorcraft-brush = { path = "crates/brush" }
vectorcraft-trace = { path = "crates/trace" }
vectorcraft-text = { path = "crates/text" }
# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
# library crate never forces them on an embedder that supplies its fonts at run time.
vectorcraft-text = { path = "crates/text", default-features = false }
vectorcraft-effects = { path = "crates/effects" }
vectorcraft-three-d = { path = "crates/three-d" }
vectorcraft-render = { path = "crates/render" }
@@ -37,6 +40,7 @@ vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -57,16 +61,24 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid
wat = "1.228"
# rendering / formats
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
# vectorcraft-text gives it. The other default features stay.
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
krilla = "0.8"
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
# crate turns `text` on.
usvg = { version = "0.48", default-features = false, features = ["svgz"] }
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
rustix = { version = "1.1", default-features = false }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
@@ -107,3 +119,9 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
+20
View File
@@ -72,3 +72,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
Assets already bundled through Hayro's default features retain their licences:
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
+18 -2
View File
@@ -73,6 +73,16 @@
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-6-revolve.png" alt="A still life of a vase, a bowl, a sphere and a bottle made with 3D Revolve; the vase is selected and the Revolve dialog shows its rotation, cap and view settings beside the rotation gizmo" width="100%">
<p align="center"><sub><b>3D Revolve</b>: smooth vector shading, caps and a rotation gizmo · <code>examples/still-life.vectorcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-7-dimensions.png" alt="A bracket drawing in the light theme with linear, angular, diameter and radius dimensions; the Dimension tool is active and the Properties panel shows the selected dimension's units, precision and style" width="100%">
<p align="center"><sub><b>Dimension tool</b>: lengths, angles and radii that follow the art they measure · <code>examples/bracket-drawing.vectorcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
@@ -108,6 +118,12 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/images/art-still-life.png" alt="Still life: a teal vase, a coral bowl, a white sphere and a mustard bottle on a warm table, all made with 3D Revolve" width="100%">
<p align="center"><sub><b>Still Life</b>: four revolved profiles with smooth vector shading</sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/art-repeat.png" alt="Radial Repeat mandala: teal petals around a coral center, ringed by yellow dots" width="100%">
<p align="center"><sub><b>Radial Repeat</b>: a live mandala</sub></p>
@@ -211,7 +227,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu
all of Illustrator): 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, PSD out and PSD/PSB in as their merged image, Print, Package).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, 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
@@ -220,7 +236,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
+33 -5
View File
@@ -1,12 +1,15 @@
# VectorCraft Roadmap
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -35,7 +38,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (50–85 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@@ -52,7 +55,7 @@ everything is measured from our code against Illustrator's public documentation.
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
| Localization (12 key languages) | 74% (measured) | 50–85 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
@@ -102,14 +105,14 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Hindi | `hi` | none | 0% |
| Arabic | `ar` | none (no right-to-left interface) | 0% |
| French | `fr` | full | 100% |
| Portuguese (Brazil) | `pt-br` | partial | 68% |
| Portuguese (Brazil) | `pt-br` | full | 100% |
| Indonesian | `id` | none | 0% |
| Japanese | `ja` | full | 100% |
| German | `de` | full | 98% |
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | full | 100% |
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -125,10 +128,28 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
| Date | What landed |
|---|---|
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With the Direct Selection tool, double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character; pressing Escape finishes editing and returns to the selection tool used before double-clicking, and in isolation mode the first Escape exits editing while preserving isolation until a second Escape. With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
| 2026-10-13 | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565). SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596). Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568). The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598). The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601). SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567). PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585). The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584). GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570). Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590). The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560). Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616). Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571). Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel kLine truncated
| 2026-10-13 | Control sockets close after bounded idle, partial-line and reply-write waits, returning their connection slot (#1726); existing command processing timeout remains separate. |
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
| 2026-10-13 | Effect › 3D and Materials: the light takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %) and can shine from behind the object, for every 3D type; effects saved with the earlier direction are lit exactly as before. |
| 2026-10-13 | Effect › 3D and Materials: Perspective is the documented 0°–160° angle for every 3D type, Revolve included; effects saved with the earlier 0–100 % strength look exactly as before. |
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
@@ -155,6 +176,13 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
+30 -9
View File
@@ -4,7 +4,7 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000] [--deep]
@@ -84,7 +84,7 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
@@ -93,7 +93,13 @@ USAGE:
precision (1–7, default 3). OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read.
draws differently from its PDF part (which can be older), as long as it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
--hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit;
visible), --transparent-images (default flatten, with the art under them; omit) and
--outline-text (type as outlines instead of text).
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -108,7 +114,8 @@ USAGE:
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows).
no budget yet and prints memory use (the working set on Windows), with the peak so far on
Windows and Linux.
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
@@ -222,6 +229,9 @@ fn convert(args: &[String]) -> Result<(), String> {
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
(1.0f64, None::<u64>, None::<String>, false, false, false);
let mut decimals = None::<u64>;
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
// `transparentImages`); the engine checks their values.
let mut legacy: Vec<(&str, String)> = vec![];
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -232,24 +242,35 @@ fn convert(args: &[String]) -> Result<(), String> {
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
f => files.push(f.to_string()),
}
}
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let mut h = Headless::new();
if editing_data {
let opened = if editing_data {
let params = json!({"path": input, "editingData": "always"});
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?
} else {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
}
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
};
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
for (key, value) in legacy {
params[key] = json!(value);
}
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
// much as what writing the output did.
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
out.insert("importWarnings".into(), w.clone());
}
outln!("{r}");
Ok(())
}
+99 -16
View File
@@ -136,31 +136,63 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
Ok((handle, segment))
}
/// Resident memory of this process in MB (`ps`), if available.
/// Resident memory of this process in MB (`ps`), if available, with the most it has held so far
/// on Linux (`VmHWM` in `/proc/self/status`). The peak is None on macOS, which has no `/proc`.
#[cfg(not(windows))]
fn rss_mb() -> Option<f64> {
fn rss_mb() -> Option<(f64, Option<f64>)> {
let pid = std::process::id().to_string();
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
let peak = std::fs::read_to_string("/proc/self/status").ok().and_then(|status| vm_hwm_mb(&status));
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| (kb / 1024.0, peak))
}
/// The peak resident set size in MB from the `VmHWM` line of a `/proc/<pid>/status` text, which
/// gives it in kB. It is None when the text has no `VmHWM` line in kB.
#[cfg(any(not(windows), test))]
fn vm_hwm_mb(status: &str) -> Option<f64> {
let kb = status.lines().find_map(|line| line.strip_prefix("VmHWM:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
Some(kb / 1024.0)
}
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
/// are in physical memory, the figure `ps` reports as resident memory elsewhere. The same call
/// gives the peak working set, the most the working set has held so far.
#[cfg(windows)]
fn rss_mb() -> Option<f64> {
fn rss_mb() -> Option<(f64, Option<f64>)> {
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
let mb = |bytes: usize| bytes as f64 / (1024.0 * 1024.0);
Some((mb(counters.WorkingSetSize), Some(mb(counters.PeakWorkingSetSize))))
}
/// A tracked row of `--deep`: the work's name and its median in ms. Names are padded to 54
/// characters.
fn tracked_row(name: &str, ms: f64) -> String {
format!(" {name:<54} {ms:>10.2} ms")
}
/// A memory row of `--deep`: the reading's name and value in MB, then the peak so far when
/// there is one. Names are padded to 56 characters.
fn memory_row(name: &str, mb: f64, peak: Option<f64>) -> String {
let peak = peak.map_or_else(String::new, |peak| format!(" peak {peak:>8.0} MB"));
format!(" {name:<56} {mb:>8.0} MB{peak}")
}
/// A row of the budget table: the budget's name, the median and the budget in ms, then "ok" or
/// "OVER". Names are padded to 42 characters, as in the table's header.
fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
/// the peak so far where `rss_mb` reads one.
fn deep(n: usize) -> Result<(), String> {
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut rows: Vec<(String, f64)> = vec![];
let mut mem: Vec<(String, f64)> = vec![];
let mut mem: Vec<(String, f64, Option<f64>)> = vec![];
let mut note = |what: &str| {
if let Some(m) = rss_mb() {
mem.push((what.to_string(), m));
if let Some((m, peak)) = rss_mb() {
mem.push((what.to_string(), m, peak));
}
};
note("start");
@@ -271,14 +303,15 @@ fn deep(n: usize) -> Result<(), String> {
let saved = vectorcraft_format::save_file(&doc);
let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?;
outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened)));
note("after the SVG export and save→open→save");
outln!(" tracked (no budget yet)");
for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms");
outln!("{}", tracked_row(name, *ms));
}
// `rss_mb` reads the working set on Windows.
let memory = if cfg!(windows) { "working set" } else { "memory" };
for (what, mb) in &mem {
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
for (what, mb, peak) in &mem {
outln!("{}", memory_row(&format!("{memory} {what}"), *mb, *peak));
}
Ok(())
}
@@ -381,11 +414,11 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
let mut over = 0;
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
outln!(" {:<42} {:>10} {:>10}", "", "measured", "budget");
for (name, ms, budget) in &rows {
let ok = ms <= budget;
over += usize::from(!ok);
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
outln!("{}", budget_row(name, *ms, *budget, ok));
}
match (over, noisy) {
(0, _) => Ok(()),
@@ -427,7 +460,57 @@ mod tests {
#[cfg(windows)]
#[test]
fn windows_reads_the_working_set() {
let mb = rss_mb();
let mb = rss_mb().map(|(mb, _)| mb);
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
}
/// On Windows and Linux `rss_mb` also gives the peak: after this test writes 64 MB and frees
/// it, the peak is no more than 16 MB below the resident memory read while it held the 64 MB.
#[cfg(any(windows, target_os = "linux"))]
#[test]
fn freed_memory_stays_in_the_peak() {
let buffer = std::hint::black_box(vec![1u8; 64 << 20]);
let held = rss_mb();
drop(buffer);
let (Some((held, _)), Some((now, peak))) = (held, rss_mb()) else {
eprintln!("no memory reading (Linux without `ps`): nothing to check");
return;
};
assert!(peak.is_some_and(|peak| peak > held - 16.0), "holding 64 MB: {held:.1} MB, after freeing it: {now:.1} MB, peak {peak:?}");
}
/// `vm_hwm_mb` reads the peak resident set size from the `VmHWM` line of a status text that
/// also has a `VmRSS` line.
#[test]
fn vm_hwm_is_the_peak_resident_set_size() {
let status = "Name:\tvectorcraft-cli\nVmPeak:\t 2097152 kB\nVmSize:\t 1572864 kB\nVmHWM:\t 819200 kB\nVmRSS:\t 204800 kB\n";
assert_eq!(vm_hwm_mb(status), Some(800.0));
// A text without a `VmHWM` line gives no peak.
assert_eq!(vm_hwm_mb("Name:\tkthreadd\nState:\tS (sleeping)\n"), None);
}
/// A memory row ends with the peak when there is one; without a peak it ends with the value
/// in MB. With the longest name on Windows, the value and the peak line up with the other rows.
#[test]
fn memory_rows_end_with_the_peak_when_there_is_one() {
let row = memory_row("working set start", 346.4, Some(470.2));
assert!(row.starts_with(" working set start ") && row.ends_with(" 346 MB peak 470 MB"), "{row}");
let longest = memory_row("working set after rendering the photos with 3 renderers", 2514.0, Some(2541.0));
assert_eq!(longest.chars().count(), row.chars().count(), "{longest}\n{row}");
let row = memory_row("memory start", 346.4, None);
assert!(row.starts_with(" memory start ") && row.ends_with(" 346 MB") && !row.contains("peak"), "{row}");
}
/// With the longest names, the values of a tracked row and of the budget table's rows line
/// up with the rows that have short names.
#[test]
fn the_longest_tracked_and_budget_names_fit_their_columns() {
let longest = tracked_row("render 4 embedded 24 MP photos with 3 renderers (cold)", 774.61);
let short = tracked_row("Pathfinder crop, 300 paths", 30.94);
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
for name in ["handle drag, 5,000-anchor path (per move)", "segment drag, 5,000-anchor path (per move)"] {
let (longest, short) = (budget_row(name, 0.12, 8.0, true), budget_row("export SVG", 7.81, 500.0, true));
assert_eq!(longest.chars().count(), short.chars().count(), "{longest}\n{short}");
}
}
}
+74 -1
View File
@@ -86,7 +86,10 @@ fn help_lists_readable_formats() {
#[test]
fn help_lists_perf_deep() {
let out = ok(&["--help"]);
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
assert!(
out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times") && out.contains("with the peak so far on"),
"{out}"
);
}
/// `info` reports the import warnings: an EPS read only up to an error names it.
@@ -99,3 +102,73 @@ fn info_reports_why_an_eps_read_partly() {
assert!(w.contains("`frobnicate`"), "{v}");
assert_eq!(v["kinds"]["Path"], 1, "{v}");
}
/// #1604: convert reports what reading the input left out (here a missing linked image) as
/// importWarnings, next to the export's own warnings.
#[test]
fn convert_reports_import_warnings() {
let svg = tmp("missing-link.svg");
std::fs::write(
&svg,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image width="40" height="40" href="nowhere-to-be-found.png"/></svg>"#,
)
.unwrap();
let png = tmp("missing-link.png");
let out: Value = serde_json::from_str(ok(&["convert", &svg, &png]).trim()).unwrap();
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
#[test]
fn convert_writes_legacy_ai_with_its_options() {
// Two artboards, a shown rectangle and a hidden one.
let src = tmp("legacy.vectorcraft");
ok(&[
"run",
"--cmd",
"file.new",
"--params",
r#"{"width":120,"height":80,"artboards":2}"#,
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":10,"y":10,"width":20,"height":10}"#,
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":40,"y":10,"width":20,"height":10}"#,
"--cmd",
"object.hide",
"--export",
&src,
]);
// One file per artboard, named after it; hidden objects in a hidden layer by default.
let out = tmp("legacy.ai");
let r: Value = serde_json::from_str(&ok(&["convert", &src, &out, "--ai-version", "7"])).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(files.len(), 2, "{r}");
assert!(files.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS-Adobe-3.0")), "{r}");
assert!(r["warnings"].to_string().contains("hidden layer"), "{r}");
// One artboard is the file itself; the hidden rectangle left out.
let one = tmp("legacy-one.ai");
let r: Value = serde_json::from_str(&ok(&[
"convert",
&src,
&one,
"--ai-version",
"7",
"--range",
"1",
"--hidden-objects",
"omit",
"--transparent-images",
"omit",
]))
.unwrap();
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
// A value the engine doesn't take is an error.
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
}
+1 -1
View File
@@ -28,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+16 -3
View File
@@ -2,12 +2,25 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="VectorCraft">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="theme-color" content="#262626">
<title>VectorCraft</title>
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#vectorcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626;
overscroll-behavior: none; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; }
/* Inside the safe areas (notch, home indicator) of a full-screen app. */
#vectorcraft_canvas { position: absolute; top: env(safe-area-inset-top, 0); right: env(safe-area-inset-right, 0);
bottom: env(safe-area-inset-bottom, 0); left: env(safe-area-inset-left, 0);
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
height: calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
display: block; touch-action: none; }
#vectorcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
</style>
+5 -1
View File
@@ -9,7 +9,9 @@
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
//! `Services::inbox`;
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
//! hover, palm rejection, fingers navigate once a pen has been seen).
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
@@ -18,6 +20,8 @@
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
#[cfg(target_arch = "wasm32")]
mod web;
+395
View File
@@ -0,0 +1,395 @@
//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in
//! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from
//! a finger and see each touch twice (as a pointer and as a touch event).
//!
//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the
//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers
//! where the pen can (Apple Pencil hover).
//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first
//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates
//! only once a pen has been seen ([`touch::set_source`]), for this page session only.
//! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers
//! already down (a palm resting first).
//!
//! Mouse and trackpad events (a Magic Keyboard) are left to eframe. Until the first pen
//! contact, fingers and their DOM touch events also stay with eframe (including its text agent).
#[cfg(target_arch = "wasm32")]
use std::cell::RefCell;
#[cfg(target_arch = "wasm32")]
use std::rc::Rc;
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
use vectorcraft_ui_egui::touch::{self, Source};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
/// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger).
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
/// The pen and finger contacts followed, and the egui events they made since the last frame.
#[derive(Default)]
pub struct Pointers {
events: Vec<Event>,
/// The pen pressing, by pointer id.
pen: Option<i32>,
/// The fingers down, by pointer id.
fingers: Vec<i32>,
/// The contact driving egui's pointer.
primary: Option<i32>,
button: Option<PointerButton>,
}
#[cfg(target_arch = "wasm32")]
pub type SharedPointers = Rc<RefCell<Pointers>>;
/// Browser-independent snapshot, also used by the routing regression tests.
struct Input<'a> {
pointer_type: &'a str,
id: i32,
button: i16,
pressure: f32,
modifiers: egui::Modifiers,
}
// Match eframe's button_from_mouse_event, including barrel/right and auxiliary buttons.
fn button(number: i16) -> Option<PointerButton> {
match number {
0 => Some(PointerButton::Primary),
1 => Some(PointerButton::Middle),
2 => Some(PointerButton::Secondary),
3 => Some(PointerButton::Extra1),
4 => Some(PointerButton::Extra2),
_ => None,
}
}
impl Pointers {
/// The events since the last frame, for the frame's raw input.
pub fn take(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force });
}
fn press(&mut self, id: i32, pos: Pos2, button: PointerButton, modifiers: egui::Modifiers) {
self.primary = Some(id);
self.button = Some(button);
self.events.push(Event::ModifiersChanged(modifiers));
self.events.push(Event::PointerMoved(pos));
self.events.push(Event::PointerButton { pos, button, pressed: true, modifiers });
}
fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
self.primary = None;
if let Some(button) = self.button.take() {
self.events.push(Event::ModifiersChanged(modifiers));
self.events.push(Event::PointerButton { pos, button, pressed: false, modifiers });
}
}
/// Cancel every finger down (a palm under a pen landing).
fn cancel_fingers(&mut self, pos: Pos2, modifiers: egui::Modifiers) {
for id in std::mem::take(&mut self.fingers) {
self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None);
if self.primary == Some(id) {
self.release(pos, modifiers);
self.events.push(Event::PointerGone);
}
}
}
/// The paired DOM touch events must follow the same routing as pointer events.
fn captures_touch(&self, on_canvas: bool) -> bool {
touch::pen_seen() && (on_canvas || self.pen.is_some() || !self.fingers.is_empty())
}
/// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe).
fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool {
let id = e.id;
match e.pointer_type {
"pen" => {
let force = Some(e.pressure.clamp(0.0, 1.0));
match kind {
"pointerdown" => {
if !on_canvas && self.pen.is_none() {
return false;
}
let Some(button) = button(e.button) else { return false };
touch::set_source(Source::Pen);
self.cancel_fingers(pos, e.modifiers);
self.pen = Some(id);
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
self.press(id, pos, button, e.modifiers);
}
"pointermove" if self.pen == Some(id) => {
// The positions the pen reported between two events (up to 240 a second
// on an iPad), each with its pressure, so fast strokes keep their curve.
for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) {
self.touch(PEN_DEVICE, id, TouchPhase::Move, c.0, Some(c.1));
self.events.push(Event::PointerMoved(c.0));
}
self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force);
self.events.push(Event::PointerMoved(pos));
}
// Hovering (Apple Pencil hover): the pointer follows without pressing.
"pointermove" => {
if self.primary.is_some() {
return true;
}
if !on_canvas {
return false;
}
self.events.push(Event::PointerMoved(pos));
}
"pointerup" | "pointercancel" if self.pen == Some(id) => {
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
self.pen = None;
self.touch(PEN_DEVICE, id, phase, pos, None);
self.release(pos, e.modifiers);
}
// A pen leaving the hover range, or an event of a press we didn't take.
_ => {
if kind == "pointerleave" && self.primary.is_none() && on_canvas {
self.events.push(Event::PointerGone);
}
return self.pen.is_some();
}
}
true
}
// Keep eframe's touch/IME path unchanged on touch-only devices. Reloading the
// page resets this policy; an old localStorage marker is deliberately ignored.
"touch" if !touch::pen_seen() => false,
"touch" => {
match kind {
"pointerdown" => {
// Palm rejection: nothing presses while the pen does.
if self.pen.is_some() {
return true;
}
if !on_canvas && self.fingers.is_empty() {
return false;
}
self.fingers.push(id);
self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None);
if self.primary.is_none() && self.fingers.len() == 1 {
touch::set_source(Source::Finger);
self.press(id, pos, PointerButton::Primary, e.modifiers);
}
}
"pointermove" if self.fingers.contains(&id) => {
self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None);
if self.primary == Some(id) {
self.events.push(Event::PointerMoved(pos));
}
}
"pointerup" | "pointercancel" if self.fingers.contains(&id) => {
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
self.fingers.retain(|f| *f != id);
self.touch(FINGER_DEVICE, id, phase, pos, None);
if self.primary == Some(id) {
self.release(pos, e.modifiers);
self.events.push(Event::PointerGone);
}
}
// A rejected palm's later events, or one we never took.
_ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas,
}
true
}
"mouse" => {
if kind == "pointerdown" {
touch::set_source(Source::Mouse);
}
false
}
_ => false,
}
}
}
/// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch
/// events that follow them), for the runner's frames. Mouse events stay eframe's.
#[cfg(target_arch = "wasm32")]
pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> {
let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into();
let options = web_sys::AddEventListenerOptions::new();
options.set_capture(true);
options.set_passive(false);
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
let (pointers, canvas) = (pointers.clone(), canvas.clone());
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| {
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
let rect = canvas.get_bounding_client_rect();
let z = runner.egui_ctx().zoom_factor();
let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z);
let at = |x: i32, y: i32| egui::pos2((x as f32 - rect.left() as f32) / z, (y as f32 - rect.top() as f32) / z);
let coalesced: Vec<(Pos2, f32)> = if kind == "pointermove"
&& e.pointer_type() == "pen"
// Safari before 17 hasn't it, and calling a missing method would abort the app.
&& js_sys::Reflect::has(&e, &"getCoalescedEvents".into()).unwrap_or(false)
{
e.get_coalesced_events()
.iter()
.filter_map(|c| c.dyn_into::<web_sys::PointerEvent>().ok())
.map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0)))
.collect()
} else {
vec![]
};
let pointer_type = e.pointer_type();
let input = Input {
pointer_type: &pointer_type,
id: e.pointer_id(),
button: e.button(),
pressure: e.pressure(),
// Same semantics as eframe's modifiers_from_mouse_event.
modifiers: egui::Modifiers {
alt: e.alt_key(),
ctrl: e.ctrl_key(),
shift: e.shift_key(),
mac_cmd: e.meta_key(),
command: e.ctrl_key() || e.meta_key(),
},
};
if !pointers.borrow_mut().feed(kind, &input, pos, &coalesced, on_canvas) {
return;
}
e.stop_immediate_propagation();
// No compatibility mouse events, no text selection or magnifier.
e.prevent_default();
if kind == "pointerup" {
// Keys typed next (a Magic Keyboard) go to the app.
canvas.focus().ok();
// Safari lets a page open the share sheet, a download or the on-screen keyboard
// only during the user's gesture: handle the release now.
runner.logic();
}
runner.egui_ctx().request_repaint();
})?;
}
// The touch events behind those pointer events: eframe would see each touch a second time,
// and Safari would scroll, zoom or select.
for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] {
let (pointers, canvas) = (pointers.clone(), canvas.clone());
runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| {
let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::<web_sys::EventTarget>());
let p = pointers.borrow();
if p.captures_touch(on_canvas) {
e.stop_immediate_propagation();
if e.cancelable() {
e.prevent_default();
}
}
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn input(pointer_type: &str, button: i16) -> Input<'_> {
Input { pointer_type, id: 7, button, pressure: 0.6, modifiers: egui::Modifiers::default() }
}
fn reset() {
touch::set_pen_seen(false);
touch::set_source(Source::Mouse);
}
#[test]
fn pen_buttons_and_modifiers_survive_press_release_and_cancel() {
for (number, expected) in [
(0, PointerButton::Primary),
(1, PointerButton::Middle),
(2, PointerButton::Secondary),
(3, PointerButton::Extra1),
(4, PointerButton::Extra2),
] {
for end in ["pointerup", "pointercancel"] {
reset();
let mut p = Pointers::default();
let mut e = input("pen", number);
e.modifiers = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true };
let pos = Pos2::new(30.0, 40.0);
assert!(p.feed("pointerdown", &e, pos, &[], true));
assert!(p.feed(end, &e, pos, &[], false));
let buttons: Vec<_> = p
.take()
.into_iter()
.filter_map(|e| match e {
Event::PointerButton { button, pressed, modifiers, .. } => Some((button, pressed, modifiers)),
_ => None,
})
.collect();
assert_eq!(buttons, [(expected, true, e.modifiers), (expected, false, e.modifiers)]);
assert!(p.pen.is_none() && p.primary.is_none());
}
}
reset();
}
#[test]
fn touch_only_and_mouse_input_stay_with_eframe_and_session_can_reset() {
reset();
let mut p = Pointers::default();
let pos = Pos2::new(10.0, 20.0);
for pointer_type in ["touch", "mouse"] {
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
assert!(!p.feed(kind, &input(pointer_type, 0), pos, &[], true));
}
}
assert!(p.take().is_empty());
assert!(!p.captures_touch(true), "eframe must also receive DOM touch events before a pen");
assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true));
assert!(p.feed("pointerup", &input("pen", 0), pos, &[], true));
assert!(p.captures_touch(true));
assert!(p.feed("pointerdown", &input("touch", 0), pos, &[], true));
assert!(p.feed("pointerup", &input("touch", 0), pos, &[], true));
// A new page starts from false, without reading any old persistent marker.
reset();
let mut next_page = Pointers::default();
assert!(!next_page.feed("pointerdown", &input("touch", 0), pos, &[], true));
assert!(next_page.take().is_empty());
assert!(!next_page.captures_touch(true));
}
#[test]
fn finger_modifiers_and_palm_cancellation_are_preserved() {
reset();
touch::set_source(Source::Pen);
let mut p = Pointers::default();
let pos = Pos2::new(10.0, 20.0);
let mut finger = input("touch", 0);
finger.id = 10;
finger.modifiers.shift = true;
assert!(p.feed("pointerdown", &finger, pos, &[], true));
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: true, modifiers, .. } if modifiers.shift)));
let pen = input("pen", 0);
assert!(p.feed("pointerdown", &pen, pos, &[], true));
assert!(p.take().iter().any(|e| matches!(e, Event::Touch { phase: TouchPhase::Cancel, .. })));
assert!(p.fingers.is_empty());
assert!(p.feed("pointermove", &finger, pos, &[], true));
assert!(p.take().is_empty(), "a resting palm cannot move the pen");
assert!(p.feed("pointerup", &pen, pos, &[], true));
assert!(p.feed("pointerdown", &finger, pos, &[], true));
assert!(p.feed("pointerup", &finger, pos, &[], true));
assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift)));
reset();
}
#[test]
fn unknown_pen_buttons_are_not_reinterpreted_as_primary() {
reset();
let mut p = Pointers::default();
assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true));
assert!(p.take().is_empty());
assert!(!touch::pen_seen());
}
}
+52 -7
View File
@@ -17,6 +17,7 @@ use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
use crate::pencil::SharedPointers;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
@@ -47,6 +48,8 @@ struct Host {
place_inbox: PlaceInbox,
drag: DragPos,
pasted: Rc<RefCell<Pasted>>,
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
pointers: SharedPointers,
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
/// restart keeps them, so the tab stays the owner of its recovery area.
locks: Option<WebLocks>,
@@ -73,6 +76,7 @@ pub fn start() {
place_inbox: Arc::default(),
drag: DragPos::default(),
pasted: Rc::default(),
pointers: SharedPointers::default(),
locks,
restarts: 0,
};
@@ -96,6 +100,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The app until the new runner takes it (still here if starting fails before).
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
let (pointers, pen_canvas) = (host.pointers.clone(), canvas.clone());
let result = runner
.start(
canvas,
@@ -127,6 +132,9 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
.await;
match (result, kept) {
(Ok(()), _) => {
if let Err(e) = crate::pencil::install(&runner, &pen_canvas, &pointers) {
log::error!("vectorcraft-web: no pen input: {}", js_err(e));
}
if let Some(el) = element(LOADING_ID) {
el.remove();
}
@@ -310,7 +318,7 @@ fn notice(lines: &[&str]) {
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
@@ -381,6 +389,7 @@ impl eframe::App for WebShell {
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
raw.events.extend(self.host.pointers.borrow_mut().take());
if let Some(app) = &mut self.app {
app.raw_input_hook(raw);
}
@@ -397,6 +406,7 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
let screen_color = eye_dropper(ctx.clone());
Services {
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
@@ -434,11 +444,10 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
ctx.request_repaint();
});
})),
download: Some(Box::new(|name: &str, bytes: &[u8]| {
if let Err(e) = download(name, bytes) {
log::error!("download of {name} failed: {e}");
}
})),
// Sample Color from Screen: the browser's EyeDropper, where it has one.
screen_color,
// A download that couldn't be started fails the save or export it is for (#1693).
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
inbox: Some(inbox),
recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog.
@@ -447,6 +456,41 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
}
}
/// Sample Color from Screen in the browser: the EyeDropper API
/// (<https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper>), which only some browsers have,
/// on secure (HTTPS) pages; `None` without it, and the command is then gray. `open()` needs a
/// transient user activation (otherwise it fails with a `NotAllowedError`) and resolves with the
/// picked color's `sRGBHex`; Escape rejects it with an `AbortError`.
fn eye_dropper(ctx: egui::Context) -> Option<vectorcraft_ui_egui::screen_color::StartSampler> {
use js_sys::{Function, Promise, Reflect};
use wasm_bindgen::JsValue;
let window = web_sys::window()?;
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
Some(Box::new(move || {
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(js_err)?;
let open = Reflect::get(&eye, &JsValue::from_str("open")).map_err(js_err)?.dyn_into::<Function>().map_err(js_err)?;
let promise = open.call0(&eye).map_err(js_err)?.dyn_into::<Promise>().map_err(js_err)?;
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let answer = match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(result) => Reflect::get(&result, &JsValue::from_str("sRGBHex"))
.ok()
.and_then(|hex| hex.as_string())
.and_then(|hex| vectorcraft_ui_egui::screen_color::srgb_from_hex(&hex))
.map(Some)
.ok_or_else(|| "the browser's EyeDropper answered no color".to_string()),
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|n| n.as_string()).as_deref() == Some("AbortError") => Ok(None),
Err(e) => Err(js_err(e)),
};
// The UI may be gone (the page closing): nothing waits for the color then.
let _ = tx.send(answer);
ctx.request_repaint();
});
Ok(rx)
}))
}
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock, and with a Web
@@ -517,7 +561,8 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
// The download has started: a timer that can't be set leaves the URL allocated, no more.
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
Ok(())
}
+5 -1
View File
@@ -55,7 +55,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
@@ -80,3 +80,7 @@ wayland-protocols = { version = "0.32", features = ["client"] }
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[dev-dependencies]
# Bound socket buffers in real loopback transport deadline regressions.
socket2 = { workspace = true }
+85
View File
@@ -0,0 +1,85 @@
//! Absolute transport budgets, independent of the time spent executing a request.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::time::{Duration, Instant};
fn deadline(budget: Duration) -> io::Result<Instant> {
Instant::now().checked_add(budget).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "control transport deadline out of range"))
}
fn remaining(until: Option<Instant>) -> io::Result<Duration> {
until
.and_then(|d| d.checked_duration_since(Instant::now()))
.filter(|d| !d.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "control transport deadline exceeded"))
}
pub(super) struct DeadlineReader {
stream: TcpStream,
idle: Duration,
line: Duration,
until: Option<Instant>,
waiting_for_first_byte: bool,
}
impl DeadlineReader {
pub(super) fn new(stream: TcpStream, idle: Duration, line: Duration) -> Self {
Self { stream, idle, line, until: None, waiting_for_first_byte: false }
}
pub(super) fn begin_line(&mut self, buffered: bool) -> io::Result<()> {
// A partial next request already in BufReader must not get an idle budget too.
self.waiting_for_first_byte = !buffered;
self.until = Some(deadline(if buffered { self.line } else { self.idle })?);
Ok(())
}
}
impl Read for DeadlineReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
self.stream.set_read_timeout(Some(remaining(self.until)?))?;
let n = self.stream.read(buf)?;
if n > 0 && self.waiting_for_first_byte {
self.until = Some(deadline(self.line)?);
self.waiting_for_first_byte = false;
}
Ok(n)
}
}
pub(super) struct DeadlineWriter {
stream: TcpStream,
budget: Duration,
until: Option<Instant>,
}
impl DeadlineWriter {
pub(super) fn new(stream: TcpStream, budget: Duration) -> Self {
Self { stream, budget, until: None }
}
pub(super) fn begin_reply(&mut self) -> io::Result<()> {
self.until = Some(deadline(self.budget)?);
Ok(())
}
}
impl Write for DeadlineWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
// Each partial write gets the remaining budget, never a fresh full timeout.
self.stream.set_write_timeout(Some(remaining(self.until)?))?;
self.stream.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.stream.set_write_timeout(Some(remaining(self.until)?))?;
self.stream.flush()
}
}
+384 -23
View File
@@ -7,7 +7,7 @@
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -17,11 +17,45 @@ use std::time::Duration;
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
#[path = "control_deadlines.rs"]
mod deadlines;
use deadlines::{DeadlineReader, DeadlineWriter};
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
/// Size in bytes of the buffer that [`write_line`] writes a reply line through.
const REPLY_CHUNK: usize = 64 * 1024;
/// How long a request waits for the app's reply.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// The reply to a request the app didn't answer in [`REPLY_WAIT`]. One it had not started is
/// withdrawn, so it never runs after its caller was told it failed; one it is running can't be
/// stopped, and the reply says its outcome isn't known.
fn timed_out(ticket: &vectorcraft_ui_egui::ControlTicket) -> Value {
let error = if ticket.give_up() {
"timeout: the app did not start the request in time; it was withdrawn and will not run"
} else {
"timeout: the app is still running the request; it may yet complete"
};
json!({"ok": false, "error": error})
}
#[derive(Clone, Copy)]
struct ConnectionTimeouts {
idle: Duration,
line: Duration,
write: Duration,
}
impl Default for ConnectionTimeouts {
fn default() -> Self {
Self { idle: Duration::from_secs(300), line: Duration::from_secs(30), write: Duration::from_secs(60) }
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
@@ -39,31 +73,78 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
for stream in listener.incoming().flatten() {
let Some(slot) = acquire_slot(&open) else {
let mut out = DeadlineWriter::new(stream, ConnectionTimeouts::default().write);
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
if out.begin_reply().is_ok() {
let _ = write_line(&mut out, &json!({"ok": false, "error": "too many control connections"}));
}
continue;
};
if let Err(e) = spawn_connection(stream, tx.clone(), ctx.clone(), slot, ConnectionTimeouts::default()) {
eprintln!("vectorcraft: control worker failed to start: {e}");
}
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
struct ConnectionSlot(Arc<AtomicUsize>);
fn acquire_slot(open: &Arc<AtomicUsize>) -> Option<ConnectionSlot> {
let mut n = open.load(Ordering::SeqCst);
loop {
if n >= MAX_CONNECTIONS {
return None;
}
match open.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => return Some(ConnectionSlot(Arc::clone(open))),
Err(current) => n = current,
}
}
}
impl Drop for ConnectionSlot {
fn drop(&mut self) {
// Each successful acquisition creates exactly one non-cloneable guard.
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
fn spawn_connection(
stream: TcpStream,
tx: Sender<ControlRequest>,
ctx: egui::Context,
slot: ConnectionSlot,
timeouts: ConnectionTimeouts,
) -> std::io::Result<std::thread::JoinHandle<()>> {
std::thread::Builder::new().name("vectorcraft-control".into()).spawn(move || {
let _slot = slot;
serve_socket(stream, timeouts, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let ticket = req.ticket();
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
});
})
}
fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl FnMut(String, Value) -> Option<Value>) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
let read = DeadlineReader::new(read, timeouts.idle, timeouts.line);
// Replies reach the writer in [`REPLY_CHUNK`] pieces ([`write_line`]), so checking the remaining
// budget costs one socket option call per piece, not one per JSON token.
let out = DeadlineWriter::new(stream, timeouts.write);
serve_lines_with(
read,
out,
handle,
|reader| {
let buffered = !reader.buffer().is_empty();
reader.get_mut().begin_line(buffered)
},
DeadlineWriter::begin_reply,
);
}
/// One parsed line.
@@ -109,7 +190,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The line's deadline ran out (Windows reports it as TimedOut, Unix as WouldBlock).
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
@@ -127,15 +212,39 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
Ok(true)
}
/// Write `v` and a newline to `out` through a buffer of [`REPLY_CHUNK`] bytes, then flush `out`.
fn write_line(out: &mut impl Write, v: &Value) -> std::io::Result<()> {
let mut out = BufWriter::with_capacity(REPLY_CHUNK, out);
writeln!(out, "{v}")?;
out.flush()
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
#[cfg(test)]
fn serve_lines<R: Read, W: Write>(read: R, out: W, handle: impl FnMut(String, Value) -> Option<Value>) {
serve_lines_with(read, out, handle, |_| Ok(()), |_| Ok(()));
}
fn serve_lines_with<R: Read, W: Write>(
read: R,
mut out: W,
mut handle: impl FnMut(String, Value) -> Option<Value>,
mut begin_line: impl FnMut(&mut BufReader<R>) -> std::io::Result<()>,
mut begin_reply: impl FnMut(&mut W) -> std::io::Result<()>,
) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
if begin_line(&mut reader).is_err() {
return;
}
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
// Nothing of a line arrived (the idle wait ran out, or the peer is gone): close without
// a word. A line nobody asked for would be read as the reply to the client's next request.
Err(_) if buf.is_empty() => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
@@ -145,7 +254,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
if begin_reply(&mut out).and_then(|()| write_line(&mut out, &r)).is_err() {
return;
}
continue;
@@ -153,7 +262,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
},
};
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
let _ = begin_reply(&mut out).and_then(|()| write_line(&mut out, &json!({"ok": false, "error": reject})));
return;
}
}
@@ -162,6 +271,164 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
fn short_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
}
fn socket_fixture(
timeouts: ConnectionTimeouts,
handle: impl FnMut(String, Value) -> Option<Value> + Send + 'static,
) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (finished, done) = channel();
let worker = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap();
serve_socket(stream, timeouts, handle);
finished.send(()).unwrap();
});
let client = TcpStream::connect(addr).unwrap();
socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
(client, done, worker)
}
fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) {
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
// Always unblock the old implementation too, so a failed regression leaves no worker behind.
let _ = client.shutdown(std::net::Shutdown::Both);
drop(client);
worker.join().unwrap();
assert!(stopped, "the server retained a stalled connection beyond its transport deadline");
}
#[test]
fn idle_control_socket_releases_its_worker() {
let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
expect_socket_deadline(client, done, worker);
}
/// An idle connection is closed without a line: a client that sends its next request late
/// must not find an error waiting where its reply should be.
#[test]
fn idle_expiry_closes_without_writing_a_line() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
let mut got = Vec::new();
client.read_to_end(&mut got).unwrap();
assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection");
expect_socket_deadline(client, done, worker);
}
/// A line that stalls is refused in words a client's author can act on, not the system's.
#[test]
fn stalled_control_line_is_refused_with_the_reason() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{\"method\":").unwrap();
let mut line = String::new();
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
assert_eq!(
serde_json::from_str::<Value>(&line).unwrap(),
json!({"ok": false, "error": "request line not completed in time; closing the connection"})
);
expect_socket_deadline(client, done, worker);
}
#[test]
fn partial_control_line_releases_its_worker() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{\"method\":").unwrap();
expect_socket_deadline(client, done, worker);
}
#[test]
fn trickled_control_line_has_an_absolute_deadline() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{").unwrap();
let mut writer = client.try_clone().unwrap();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let writing = Arc::clone(&stop);
let trickle = std::thread::spawn(move || {
while !writing.load(Ordering::SeqCst) {
if writer.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
stop.store(true, Ordering::SeqCst);
// Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well.
let _ = client.shutdown(std::net::Shutdown::Both);
trickle.join().unwrap();
worker.join().unwrap();
assert!(stopped, "trickled bytes restarted the line's deadline");
}
#[test]
fn a_nonreading_control_peer_cannot_retain_the_writer() {
let payload = "x".repeat(2 * 1024 * 1024);
let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload})));
client.write_all(b"{\"method\":\"large\"}\n").unwrap();
expect_socket_deadline(client, done, worker);
}
#[test]
fn persistent_control_requests_get_fresh_transport_deadlines() {
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() };
let (mut client, done, worker) = socket_fixture(timeouts, |_, _| {
// Application processing is not transport time; begin the reply budget afterwards.
std::thread::sleep(Duration::from_millis(200));
Some(json!({"ok": true}))
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
}
client.shutdown(std::net::Shutdown::Write).unwrap();
done.recv_timeout(Duration::from_secs(3)).unwrap();
worker.join().unwrap();
}
#[test]
fn buffered_partial_control_line_also_expires() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| Some(json!({"ok": true})));
client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap();
let mut line = String::new();
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], 1);
expect_socket_deadline(client, done, worker);
}
#[test]
fn timed_out_worker_returns_its_connection_slot() {
let open = Arc::new(AtomicUsize::new(0));
let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect();
let slot = acquire_slot(&open).unwrap();
assert!(acquire_slot(&open).is_none());
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (stream, _) = listener.accept().unwrap();
let (tx, _rx) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap();
let start = std::time::Instant::now();
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) {
std::thread::sleep(Duration::from_millis(10));
}
let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS;
// Clean up an old implementation that never returned its slot as well.
let _ = client.shutdown(std::net::Shutdown::Both);
worker.join().unwrap();
assert!(released, "the deadline did not free capacity for the next client");
let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot");
drop(replacement);
drop(held);
assert_eq!(open.load(Ordering::SeqCst), 0);
}
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
@@ -173,6 +440,50 @@ mod tests {
(replies, calls)
}
/// Counts the `write` calls made on it and keeps the bytes they write.
#[derive(Default)]
struct Writes {
calls: usize,
bytes: Vec<u8>,
}
impl Write for Writes {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.calls += 1;
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200.
fn inspect_reply(n: u64) -> Value {
let paths: Vec<Value> = (0..n)
.map(|k| {
let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0);
json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261",
"id": k + 2, "kind": "Path", "locked": false, "name": "<Path>", "stroke": "None", "strokeWidth": 0.0, "visible": true})
})
.collect();
json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}})
}
/// #1725: a request its caller gave up before the app took it never runs; one the app took
/// first can't be withdrawn, and the timeout says so.
#[test]
fn a_timed_out_request_is_withdrawn_unless_it_started() {
let (waiting, _rx) = ControlRequest::new("engine.execute", json!({}));
assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run"));
assert!(!waiting.claim(), "the app skips a withdrawn request");
let (running, _rx) = ControlRequest::new("engine.execute", json!({}));
assert!(running.claim());
assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running"));
assert!(!running.claim(), "a request is taken once");
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
@@ -183,6 +494,25 @@ mod tests {
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn replies_are_written_in_few_large_writes() {
for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] {
let mut out = Writes::default();
let mut once = Some(reply.clone());
serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take());
let mut want = reply;
want["id"] = json!(1);
let text = String::from_utf8(out.bytes).unwrap();
// The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB.
assert_eq!(text, format!("{want}\n"));
assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len());
}
// A refusal is one write as well.
let mut out = Writes::default();
serve_lines(&b"hello\n"[..], &mut out, |_, _| None);
assert_eq!(out.calls, 1);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST: the request line is rejected and the
@@ -263,6 +593,37 @@ mod tests {
assert!(lines[1].contains("closing the connection"));
}
#[test]
fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let reply = inspect_reply(10_000);
let mut want = reply.clone();
want["id"] = json!(1);
let want = format!("{want}\n");
std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(reply.clone()));
});
// The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does.
let (tx, rx) = channel();
std::thread::spawn(move || {
let mut c = TcpStream::connect(addr).unwrap();
let mut read = BufReader::new(c.try_clone().unwrap());
let mut lines = vec![];
for _ in 0..10 {
c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
let mut line = String::new();
read.read_line(&mut line).unwrap();
lines.push(line);
}
tx.send(lines).ok();
});
let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s"));
assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes");
}
#[test]
fn extra_connections_are_refused() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+538 -12
View File
@@ -37,9 +37,23 @@
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
//! the app again without that adapter in the same way.
//!
//! A driver can also crash the whole process, and then no code in it runs to start it again:
//! Intel's Direct3D 12 driver (`igd12umd64.dll`) ended 0.9.0 before its window appeared (#2243).
//! So each start writes [`START_FILE`] in the log folder before the graphics start, adds the
//! adapter once one is picked, and clears both once the first frame reaches the screen or the app
//! ends without crashing ([`Startup::track`]). The next start that finds a start still recorded
//! there leaves that adapter out, as a restart does, and keeps leaving it out; when the crash came
//! while the adapters were being listed, before one was picked, it lists OpenGL's adapters alone
//! (except on macOS, where wgpu has none). A start that `WGPU_BACKEND` or `WGPU_ADAPTER_NAME`
//! chooses leaves nothing out, and the crashes are forgotten when the app version or
//! Preferences › Performance › Graphics Processor changes, when a start shows its first frame on
//! the adapter that crashed, or when no adapter is left to show the window.
use std::io::{Read as _, Seek as _, Write as _};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
@@ -50,6 +64,14 @@ use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
/// again.
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
/// The file in the log folder that records each start of the window until its first frame
/// reaches the screen, and the adapters earlier starts crashed on ([`Startup::track`]).
pub const START_FILE: &str = "graphics-start.json";
/// The most bytes of [`START_FILE`] read, and the most crashed adapters it keeps.
const START_FILE_LIMIT: u64 = 64 * 1024;
const CRASHED_LIMIT: usize = 16;
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
@@ -348,6 +370,226 @@ fn parse_skip(v: &str) -> Vec<String> {
v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
}
/// Whether `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` is set: then a start uses the backends and the
/// adapter they name, and leaves out none that earlier starts crashed on.
pub fn explicit_choice() -> bool {
std::env::var_os("WGPU_BACKEND").is_some() || std::env::var_os(NAME_ENV).is_some()
}
/// How the last start ended when it ended before the window's first frame reached the screen,
/// with no error or panic to report: a crash in the graphics driver (#2243), or the process was
/// ended from outside.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Crash {
/// While the adapters were being listed, before one was picked.
Listing,
/// On the adapter with this [`key`] and name.
Adapter { key: String, name: String },
}
/// What a start leaves out because earlier starts crashed ([`Startup::track`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LeftOut {
/// The adapters ([`key`]s) that crashed a start.
pub adapters: Vec<String>,
/// A start crashed while listing the adapters: list OpenGL's alone. Never on macOS, where wgpu
/// has no OpenGL adapters to fall back to.
pub opengl_only: bool,
/// The crash the last start ended in, when this start leaves something out for it.
pub crash: Option<Crash>,
}
impl LeftOut {
/// `backends` to list: OpenGL alone after a crash while listing, when it is among them;
/// otherwise `backends`.
pub fn backends(&self, backends: Backends) -> Backends {
if self.opengl_only && backends.contains(Backends::GL) { Backends::GL } else { backends }
}
}
/// An adapter in [`START_FILE`]: its [`key`] and name.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Recorded {
key: String,
name: String,
}
/// The start in progress, in [`START_FILE`].
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct Starting {
/// The adapter picked; `None` while the adapters are being listed.
adapter: Option<Recorded>,
/// The listing found an adapter of a backend other than OpenGL.
native: bool,
}
/// What [`START_FILE`] holds.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct StartRecord {
/// The app version and the Graphics Processor preference (`gpuPreference`) it was written
/// under: the crashes count only for the same ones.
version: String,
preference: String,
/// The start in progress, from before the graphics start until its first frame reaches the
/// screen or the app ends without crashing.
starting: Option<Starting>,
/// The adapters that crashed a start, oldest first.
crashed: Vec<Recorded>,
/// A start crashed while listing the adapters.
listing_crashed: bool,
}
impl StartRecord {
/// This start's record, made from the one the last start left (`previous`; `None` when there
/// is none or it can't be read), and the crash that start ended in, if it did. A record
/// written by another version or under another `preference` is dropped.
fn begin(previous: Option<Self>, version: &str, preference: &str) -> (Self, Option<Crash>) {
let mut record = previous.filter(|r| r.version == version && r.preference == preference).unwrap_or_default();
let crash = record.starting.take().map(|s| match s.adapter {
Some(Recorded { key, name }) => Crash::Adapter { key, name },
None => Crash::Listing,
});
match &crash {
Some(Crash::Adapter { key, name }) if !record.crashed.iter().any(|c| &c.key == key) => {
record.crashed.push(Recorded { key: key.clone(), name: name.clone() });
if record.crashed.len() > CRASHED_LIMIT {
record.crashed.remove(0);
}
}
Some(Crash::Listing) => record.listing_crashed = true,
_ => {}
}
record.version = version.to_string();
record.preference = preference.to_string();
record.starting = Some(Starting::default());
(record, crash)
}
/// What this start leaves out: nothing when `explicit` ([`explicit_choice`]). `crash` is the
/// one [`begin`](Self::begin) found.
fn left_out(&self, crash: Option<Crash>, explicit: bool) -> LeftOut {
if explicit {
return LeftOut::default();
}
let opengl_only = self.listing_crashed && !cfg!(target_os = "macos");
let crash = crash.filter(|c| matches!(c, Crash::Adapter { .. }) || opengl_only);
LeftOut { adapters: self.crashed.iter().map(|c| c.key.clone()).collect(), opengl_only, crash }
}
/// The adapter this start picked, after a listing that found one of a backend other than
/// OpenGL when `native`.
fn picked(&mut self, key: &str, name: &str, native: bool) {
self.starting = Some(Starting { adapter: Some(Recorded { key: key.to_string(), name: name.trim().to_string() }), native });
}
/// The listing found no adapter that can show the window: nothing is left to leave out, so the
/// crashes are forgotten and the next start tries every adapter again.
fn none_left(&mut self) {
self.crashed.clear();
self.listing_crashed = false;
}
/// The window's first frame reached the screen: the adapter it was drawn on, and the listing
/// when it found an adapter of a backend other than OpenGL, no longer count as crashed.
fn presented(&mut self) {
let Some(starting) = self.starting.take() else { return };
if let Some(adapter) = starting.adapter {
self.crashed.retain(|c| c.key != adapter.key);
}
if starting.native {
self.listing_crashed = false;
}
}
/// The start ended without crashing: a normal exit, an error, or a restart on another adapter.
fn ended(&mut self) {
self.starting = None;
}
fn to_json(&self) -> String {
let recorded = |r: &Recorded| serde_json::json!({ "key": r.key, "name": r.name });
let mut v = serde_json::json!({
"version": self.version,
"gpuPreference": self.preference,
"crashed": self.crashed.iter().map(recorded).collect::<Vec<_>>(),
"listingCrashed": self.listing_crashed,
});
if let (Some(s), Some(map)) = (&self.starting, v.as_object_mut()) {
map.insert("starting".into(), serde_json::json!({ "adapter": s.adapter.as_ref().map(recorded), "native": s.native }));
}
v.to_string()
}
/// The record in `text`; `None` when it isn't one.
fn parse(text: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let recorded = |v: &serde_json::Value| -> Option<Recorded> {
Some(Recorded { key: v.get("key")?.as_str()?.to_string(), name: v.get("name")?.as_str()?.to_string() })
};
let starting = match v.get("starting") {
None => None,
Some(s) => Some(Starting {
adapter: match s.get("adapter") {
None | Some(serde_json::Value::Null) => None,
Some(a) => Some(recorded(a)?),
},
native: s.get("native")?.as_bool()?,
}),
};
let crashed = v.get("crashed")?.as_array()?;
Some(Self {
version: v.get("version")?.as_str()?.to_string(),
preference: v.get("gpuPreference")?.as_str()?.to_string(),
starting,
crashed: crashed.iter().take(CRASHED_LIMIT).map(recorded).collect::<Option<_>>()?,
listing_crashed: v.get("listingCrashed")?.as_bool()?,
})
}
}
/// [`START_FILE`], open and locked while the start runs, so that a second copy of the app starting
/// at the same time doesn't take it for the record of a start that crashed.
struct StartFile {
file: std::fs::File,
record: StartRecord,
}
impl StartFile {
/// Open `dir`'s [`START_FILE`], with the record the last start left in it (`None` when there is
/// none or it can't be read). `Ok(None)` when another copy of the app holds it.
fn open(dir: &Path) -> std::io::Result<Option<(std::fs::File, Option<StartRecord>)>> {
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(dir.join(START_FILE))?;
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
Err(std::fs::TryLockError::Error(e)) => return Err(e),
}
let mut text = String::new();
// Text that isn't UTF-8, or is longer than any record, is ignored like a damaged record.
let previous = (&file).take(START_FILE_LIMIT).read_to_string(&mut text).ok().and_then(|_| StartRecord::parse(&text));
Ok(Some((file, previous)))
}
fn write(&mut self) {
let text = self.record.to_json();
let written = self.file.set_len(0).and_then(|()| self.file.rewind()).and_then(|()| self.file.write_all(text.as_bytes()));
if let Err(e) = written {
log::warn!("{START_FILE}: {e}");
}
}
}
/// The value of `gpuPreference` the crashes are recorded under: any value this version doesn't
/// know reads as `automatic`, as in [`power_preference`].
fn preference_name(pref: Option<&str>) -> &'static str {
match pref {
Some("highPerformance") => "highPerformance",
Some("lowPower") => "lowPower",
_ => "automatic",
}
}
/// What the window's start-up leaves for [`finish`].
#[derive(Default)]
pub struct Startup {
@@ -357,6 +599,8 @@ pub struct Startup {
ui: OnceLock<egui::Context>,
/// When the app was created, the files named at the start opened: the first frame is due.
ready: OnceLock<Instant>,
/// [`START_FILE`] while the start runs ([`Self::track`]).
start: Mutex<Option<StartFile>>,
}
impl Startup {
@@ -371,6 +615,82 @@ impl Startup {
// Set once, like `created`.
let _ = self.ready.set(Instant::now());
}
/// Record this start in `dir`'s [`START_FILE`] until the window's first frame reaches the
/// screen or the app ends without crashing, and say what it leaves out for the starts that
/// crashed before (see the module docs). `preference` is Preferences › Performance › Graphics
/// Processor, and `explicit` is [`explicit_choice`]. When the file can't be used (it can't be
/// written, or another copy of the app is starting), nothing is recorded or left out.
pub fn track(&self, dir: &Path, preference: Option<&str>, explicit: bool) -> LeftOut {
let (file, previous) = match StartFile::open(dir) {
Ok(Some(opened)) => opened,
Ok(None) => {
log::info!("{START_FILE}: another VectorCraft is starting, so this start isn't recorded");
return LeftOut::default();
}
Err(e) => {
log::warn!("{START_FILE}: {e}");
return LeftOut::default();
}
};
let (record, crash) = StartRecord::begin(previous, env!("CARGO_PKG_VERSION"), preference_name(preference));
let left_out = record.left_out(crash.clone(), explicit);
match &crash {
Some(Crash::Adapter { key, name }) => {
log::warn!("the last start ended on graphics adapter {name} ({key}) before the window's first frame reached the screen")
}
Some(Crash::Listing) => log::warn!("the last start ended while it listed the graphics adapters"),
None => {}
}
if explicit && (!record.crashed.is_empty() || record.listing_crashed) {
log::info!("WGPU_BACKEND or WGPU_ADAPTER_NAME is set: no adapter is left out for the starts that crashed");
}
if !left_out.adapters.is_empty() {
log::warn!("leaving out the graphics adapters earlier starts crashed on: {}", left_out.adapters.join(", "));
}
if left_out.opengl_only {
log::warn!("an earlier start crashed while it listed the graphics adapters: listing OpenGL's alone");
}
let mut file = StartFile { file, record };
file.write();
*self.start.lock().unwrap_or_else(PoisonError::into_inner) = Some(file);
left_out
}
/// [`selector`] picked the adapter with this [`key`] and name; `native` when the listing found
/// an adapter of a backend other than OpenGL.
fn picked(&self, key: &str, name: &str, native: bool) {
// Set once; eframe picks the adapter once.
let _ = self.adapter.set(key.to_string());
self.update(|r| r.picked(key, name, native), false);
}
/// [`selector`] found no adapter that can show the window.
fn none_left(&self) {
self.update(StartRecord::none_left, false);
}
/// The window's first frame reached the screen, or the window started minimized.
fn presented(&self) {
self.update(StartRecord::presented, true);
}
/// The app ends, or starts again, without crashing.
fn ended(&self) {
self.update(StartRecord::ended, true);
}
/// Change the record and write it; `last` releases the file for the next start.
fn update(&self, change: impl FnOnce(&mut StartRecord), last: bool) {
let mut start = self.start.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(file) = start.as_mut() {
change(&mut file.record);
file.write();
}
if last {
*start = None;
}
}
}
/// What [`watch_first_frame`] makes of the window's start.
@@ -396,10 +716,10 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
}
}
/// Watch the window's first frame from another thread: when it hasn't reached the screen
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
/// thread is stuck in the graphics driver.
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was
/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for
/// one that failed, and end this process, whose UI thread is stuck in the graphics driver.
pub fn watch_first_frame(startup: Arc<Startup>) {
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
@@ -409,7 +729,10 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Shown => return,
FirstFrame::Shown => {
startup.presented();
return;
}
FirstFrame::Hung => {}
}
let mut skip = skipped();
@@ -418,6 +741,8 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
FIRST_FRAME_TIMEOUT.as_secs()
);
// The new app records its own start.
startup.ended();
skip.push(failed.to_string());
match restart(&skip.join(",")) {
Ok(()) => std::process::exit(0),
@@ -433,20 +758,22 @@ pub fn watch_first_frame(startup: Arc<Startup>) {
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor`
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart
/// left out. Every adapter, the displays and the choice are logged: which GPU draws is the first
/// question in every black-window report.
/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME`, the adapters a restart
/// left out and those `crashed` ([`LeftOut::adapters`]). Every adapter, the displays and the
/// choice are logged: which GPU draws is the first question in every black-window report.
pub fn selector(
power: PowerPreference,
displays: Vec<DisplayGpu>,
compositor: Option<(u32, u32)>,
crashed: Vec<String>,
startup: Arc<Startup>,
) -> NativeAdapterSelectorMethod {
let named = std::env::var(NAME_ENV).ok();
let skip = skipped();
let left_out: Vec<String> = skip.iter().chain(&crashed).cloned().collect();
Arc::new(move |adapters, surface| {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip);
let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &left_out);
let listed: Vec<String> = candidates
.iter()
.map(|c| {
@@ -455,6 +782,8 @@ pub fn selector(
", can't show the window"
} else if skip.contains(&c.key) {
", failed before"
} else if crashed.contains(&c.key) {
", crashed an earlier start"
} else if c.composites(compositor) {
", the compositor draws with it"
} else if drives_primary {
@@ -470,6 +799,7 @@ pub fn selector(
log::info!("graphics adapters: {}", listed.join("; "));
let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
let Some((adapter, chosen)) = first else {
startup.none_left();
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
};
let why = match chosen.drives(&displays) {
@@ -480,8 +810,7 @@ pub fn selector(
(false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"),
};
log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type);
// Set once; eframe picks the adapter once.
let _ = startup.adapter.set(chosen.key.clone());
startup.picked(&chosen.key, &chosen.name, candidates.iter().any(|c| c.backend != Backend::Gl));
Ok(adapter.clone())
})
}
@@ -514,6 +843,8 @@ fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a st
/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
/// ended it.
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
// Whatever follows, this start didn't crash; a new app records its own start.
startup.ended();
let (why, graphics_failed) = match outcome {
Ok(Ok(())) => return Ok(()),
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
@@ -1101,4 +1432,199 @@ mod tests {
GRAPHICS_PANIC.store(false, Ordering::Relaxed);
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
}
/// A log folder of its own for each test.
fn start_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
/// What a start in `dir` leaves out; it then ends without crashing.
fn next_start(dir: &std::path::Path, explicit: bool) -> LeftOut {
let s = Startup::default();
let left_out = s.track(dir, None, explicit);
s.ended();
left_out
}
/// The machine of #2243: one Intel UHD Graphics 605, listed through DX12 and OpenGL, and
/// Windows' software adapter.
fn issue_2243() -> Vec<Candidate> {
vec![
pci_gpu("Intel(R) UHD Graphics 605", Backend::Dx12, DeviceType::IntegratedGpu, (0x8086, 0x3184)),
pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, (0x1414, 0x008c)),
pci_gpu("Intel(R) UHD Graphics 605", Backend::Gl, DeviceType::Other, (0x8086, 0)),
]
}
/// #2243: the driver ends the process after the adapter was picked, before the first frame.
/// The next start leaves that adapter out and draws through OpenGL, and so do the starts after
/// it, without a status-bar note.
#[test]
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
let dir = start_dir("crash");
let c = issue_2243();
let first = Startup::default();
assert_eq!(first.track(&dir, None, false), LeftOut::default());
first.picked(&c[0].key, &c[0].name, true);
// The process ends here: its file is closed and unlocked, and the start is still recorded.
drop(first);
let second = Startup::default();
let left_out = second.track(&dir, Some("automatic"), false);
assert_eq!(left_out.adapters, ["Dx12:8086:3184"]);
assert_eq!(left_out.crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "Intel(R) UHD Graphics 605".into() }));
assert!(!left_out.opengl_only);
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::DX12 | Backends::GL);
let order = adapter_order(&c, AUTOMATIC, &[], None, None, &left_out.adapters);
assert_eq!(order, [2, 1], "OpenGL on the same GPU before the software adapter");
second.picked(&c[2].key, &c[2].name, true);
second.presented();
let third = Startup::default();
let left_out = third.track(&dir, None, false);
assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept");
assert_eq!(left_out.crash, None, "the note is shown once");
third.ended();
let _ = std::fs::remove_dir_all(&dir);
}
/// A start whose first frame reached the screen, or that ended without crashing (a normal exit,
/// an error, a restart on another adapter), leaves nothing out of the next.
#[test]
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
let dir = start_dir("shown");
let c = issue_2243();
for end in [Startup::presented as fn(&Startup), Startup::ended] {
let first = Startup::default();
first.track(&dir, None, false);
first.picked(&c[0].key, &c[0].name, true);
end(&first);
drop(first);
assert_eq!(next_start(&dir, false), LeftOut::default());
}
// So does a start that ended before it picked an adapter.
let first = Startup::default();
first.track(&dir, None, false);
assert!(finish(Ok(Ok(())), &first).is_ok());
drop(first);
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a
/// device on each GPU to list it): the next starts list OpenGL's adapters alone, except on
/// macOS, where wgpu has none.
#[test]
fn a_crash_while_listing_lists_opengl_alone() {
let dir = start_dir("listing");
let first = Startup::default();
first.track(&dir, None, false);
drop(first);
let second = Startup::default();
let left_out = second.track(&dir, None, false);
assert!(left_out.adapters.is_empty());
if cfg!(target_os = "macos") {
assert_eq!(left_out, LeftOut::default());
} else {
assert_eq!(left_out.crash, Some(Crash::Listing));
assert_eq!(left_out.backends(Backends::DX12 | Backends::GL), Backends::GL);
assert_eq!(left_out.backends(Backends::PRIMARY | Backends::GL), Backends::GL);
assert_eq!(left_out.backends(Backends::METAL), Backends::METAL, "no OpenGL to fall back to");
}
// Listing OpenGL alone found nothing that can show the window: the next start lists every
// backend again.
second.none_left();
second.ended();
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that
/// start shows its first frame on the adapter that crashed, the adapter counts again.
#[test]
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
let dir = start_dir("explicit");
let c = issue_2243();
let first = Startup::default();
first.track(&dir, None, false);
first.picked(&c[0].key, &c[0].name, true);
drop(first);
assert_eq!(next_start(&dir, true), LeftOut::default());
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"], "still left out without them");
let explicit = Startup::default();
explicit.track(&dir, None, true);
explicit.picked(&c[0].key, &c[0].name, true);
explicit.presented();
assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window");
let _ = std::fs::remove_dir_all(&dir);
}
/// A damaged record, a file that can't be opened and one another starting copy of the app holds
/// are ignored: the start goes on with nothing left out.
#[test]
fn a_damaged_or_unreadable_start_file_is_ignored() {
let dir = start_dir("damaged");
std::fs::create_dir_all(&dir).unwrap();
let damaged: [&[u8]; 9] = [
b"",
b"{",
b"[]",
b"null",
b"{\"version\":1}",
b"{\"starting\":{}}",
b"\xef\xbb\xbf\0",
b"\xff\xfe{",
&[b' '; START_FILE_LIMIT as usize + 1],
];
for text in damaged {
std::fs::write(dir.join(START_FILE), text).unwrap();
assert_eq!(next_start(&dir, false), LeftOut::default(), "{:?}", String::from_utf8_lossy(&text[..text.len().min(20)]));
}
// Another copy of the app is starting: this start neither reads nor writes the file.
let c = issue_2243();
let starting = Startup::default();
starting.track(&dir, None, false);
starting.picked(&c[0].key, &c[0].name, true);
assert_eq!(next_start(&dir, false), LeftOut::default());
drop(starting);
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]);
// A folder in the file's place.
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(START_FILE)).unwrap();
assert_eq!(next_start(&dir, false), LeftOut::default());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
let starting = |key: &str| Starting { adapter: Some(Recorded { key: key.into(), name: "GPU".into() }), native: true };
let (mut record, crash) = StartRecord::begin(None, "0.9.0", "automatic");
assert_eq!(crash, None);
record.starting = Some(starting("Dx12:8086:3184"));
let (record, crash) = StartRecord::begin(Some(record), "0.9.0", "automatic");
assert_eq!(crash, Some(Crash::Adapter { key: "Dx12:8086:3184".into(), name: "GPU".into() }));
assert_eq!(record.left_out(None, false).adapters, ["Dx12:8086:3184"]);
// Written and read back.
assert_eq!(StartRecord::parse(&record.to_json()), Some(record.clone()));
assert!(StartRecord::begin(Some(record.clone()), "0.9.1", "automatic").0.crashed.is_empty());
assert!(StartRecord::begin(Some(record.clone()), "0.9.0", "highPerformance").0.crashed.is_empty());
assert_eq!(preference_name(None), "automatic");
assert_eq!(preference_name(Some("powerSaving")), "automatic");
assert_eq!(preference_name(Some("lowPower")), "lowPower");
// An adapter is listed once, and the list is bounded.
let mut record = record;
for n in 0..CRASHED_LIMIT + 4 {
for _ in 0..2 {
record.starting = Some(starting(&format!("Dx12:10de:{n:04x}")));
record = StartRecord::begin(Some(record), "0.9.0", "automatic").0;
}
}
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
}
}
-137
View File
@@ -1,137 +0,0 @@
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
//! every filter but doesn't show their names. The accessory uses the same filters and updates
//! the filename, whose extension already determines the format in the save command.
//!
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
#![allow(unsafe_code)]
use std::path::Path;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
let Some(extension) = extensions.first() else { return };
let name = panel.nameFieldStringValue().to_string();
let new = filename(&name, extension, &self.ivars().filters);
panel.setNameFieldStringValue(&NSString::from_str(&new));
}
}
);
/// The suggested file's type, or the request's default (the first filter).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = Path::new(name).extension().and_then(|e| e.to_str());
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
}
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return name.to_owned();
}
let path = Path::new(name);
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
{
path.with_extension(extension).to_string_lossy().into_owned()
} else {
format!("{name}.{extension}")
}
}
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
/// without several named file types are left to rfd.
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
let Some(mtm) = MainThreadMarker::new() else { return };
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if filters.len() < 2 {
return;
}
let Ok(handle) = window.window_handle() else { return };
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
// SAFETY: winit keeps its content view alive while this window reference is in use.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
let selected = initial_format(&pick.name, &filters);
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
for (name, _) in &accessory.ivars().filters {
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
// open. The selector above has exactly the target/action signature expected by NSControl.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
accessory.addSubview(&label);
accessory.addSubview(&popup);
}
panel.setExtensionHidden(false);
panel.setAccessoryView(Some(&accessory));
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
#[test]
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
assert_eq!(initial_format(name, FILTERS), index);
}
}
#[test]
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
for (name, expected) in
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
{
assert_eq!(filename(name, "svg", FILTERS), expected);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! macOS: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the system's color
//! sampler, `NSColorSampler` (macOS 10.15 and later), which "displays the system’s color-sampling
//! interface and returns the selected color to your app"
//! (<https://developer.apple.com/documentation/appkit/nscolorsampler>). AppKit calls the handler on
//! the main thread with the color the person clicked, or with nil when they pressed Escape. The
//! color is converted to sRGB before it is sent to the UI; AppKit clips a color outside sRGB to it
//! (Display P3 red arrives as sRGB red).
//!
//! `showSamplerWithSelectionHandler:` has no safe binding; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::channel;
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSColor, NSColorSampler, NSColorSpace};
use vectorcraft_ui_egui::screen_color::{Answer, StartSampler};
/// The system's color sampler; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context) -> StartSampler {
Box::new(move || {
MainThreadMarker::new().ok_or("the color sampler can only be shown from the main thread")?;
let (tx, rx) = channel();
let ui = ui.clone();
let handler = RcBlock::new(move |color: *mut NSColor| {
// SAFETY: AppKit passes the selected color, or nil when the person canceled, and keeps
// it alive while the handler runs.
let answer: Answer = match unsafe { color.as_ref() } {
None => Ok(None),
Some(color) => srgb(color).map(Some).ok_or_else(|| "the sampled color has no sRGB value".into()),
};
// The UI may be gone (the app quitting): nothing waits for the color then.
let _ = tx.send(answer);
ui.request_repaint();
});
let sampler = NSColorSampler::new();
// SAFETY: on the main thread (checked above); the handler has the signature AppKit calls it
// with, and AppKit keeps the block and the sampler until the session ends.
unsafe { sampler.showSamplerWithSelectionHandler(&handler) };
Ok(rx)
})
}
/// The components of `color` in sRGB, or `None` when it has no RGB form (a pattern color).
fn srgb(color: &NSColor) -> Option<[f32; 3]> {
let c = color.colorUsingColorSpace(&NSColorSpace::sRGBColorSpace())?;
Some([c.redComponent() as f32, c.greenComponent() as f32, c.blueComponent() as f32])
}
#[cfg(test)]
mod tests {
use super::*;
/// A color in another space arrives as its sRGB components.
#[test]
fn sampled_colors_convert_to_srgb() {
let red = NSColor::colorWithSRGBRed_green_blue_alpha(1.0, 0.0, 0.0, 1.0);
assert_eq!(srgb(&red), Some([1.0, 0.0, 0.0]));
// Display P3 (0.6, 0.4, 0.3) lies inside sRGB with other numbers: more red, less green and blue.
let p3 = NSColor::colorWithDisplayP3Red_green_blue_alpha(0.6, 0.4, 0.3, 1.0);
let [r, g, b] = srgb(&p3).unwrap();
assert!(r > 0.61 && g < 0.39 && b < 0.29, "converted, not read as sRGB numbers: {r} {g} {b}");
// A color outside sRGB is clipped to it.
assert_eq!(srgb(&NSColor::colorWithDisplayP3Red_green_blue_alpha(1.0, 0.0, 0.0, 1.0)), Some([1.0, 0.0, 0.0]));
let gray = NSColor::colorWithWhite_alpha(0.5, 1.0);
let [r, g, b] = srgb(&gray).unwrap();
assert!((r - g).abs() < 1e-4 && (g - b).abs() < 1e-4, "{r} {g} {b}");
}
}
+79 -7
View File
@@ -1,7 +1,7 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
//! [--in-window-menus] [files…]`
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
@@ -35,7 +35,7 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
@@ -45,6 +45,7 @@ mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod window;
mod window_frame;
mod xwayland;
use vectorcraft_engine::Session;
@@ -62,6 +63,8 @@ struct App {
graphics_lost: bool,
/// Frames the UI has run (see [`end_before_teardown`]).
frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
}
impl eframe::App for App {
@@ -72,6 +75,11 @@ impl eframe::App for App {
file_access::unconfined(|| self.logic_frame(ctx, frame));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
window_frame();
}
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
@@ -87,6 +95,11 @@ impl eframe::App for App {
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
@@ -472,9 +485,10 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(pick) => {
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
mac_save_format::attach(window, &pick);
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::Folder => {
@@ -489,7 +503,11 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
@@ -498,6 +516,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -520,6 +539,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
@@ -603,6 +625,26 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
}
}
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
const USAGE: &str = "\
Usage: vectorcraft [options] [files…]
Opens the VectorCraft desktop app with the given files.
Options:
--control <port> Start the localhost JSON-lines control server on <port>
(or VECTORCRAFT_CONTROL_PORT)
--automation-read-root <dir> Confine the files control requests read to <dir>
--automation-write-root <dir> Confine the files control requests write to <dir>
--in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit
-h, --help Print this help and exit
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
vectorcraft-cli --help
";
fn main() -> std::process::ExitCode {
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
@@ -628,6 +670,14 @@ fn main() -> std::process::ExitCode {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
// used to open (or hang without a display) instead (#2110).
"--help" | "-h" => {
// Not `print!`, which panics when standard output is closed (`| head -0`):
// there is nothing left to tell then.
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
@@ -711,6 +761,16 @@ fn main() -> std::process::ExitCode {
}
#[cfg(feature = "wgpu")]
let startup = std::sync::Arc::new(gpu::Startup::default());
// A driver that crashes the process while the window starts leaves this start recorded in the
// log folder; the next start leaves that adapter out (#2243). Runs without preferences, which
// write no log file, don't record their starts.
#[cfg(feature = "wgpu")]
let left_out = match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => startup.track(&dir, gpu_pref, gpu::explicit_choice()),
None => gpu::LeftOut::default(),
};
#[cfg(feature = "wgpu")]
let crash = left_out.crash.clone();
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
@@ -740,13 +800,14 @@ fn main() -> std::process::ExitCode {
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, startup.clone()));
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, left_out.adapters.clone(), startup.clone()));
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends = gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some());
create.instance_descriptor.backends =
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
@@ -821,6 +882,15 @@ fn main() -> std::process::ExitCode {
app.status(format!(
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
));
} else if let Some(crash) = &crash {
app.status(match crash {
gpu::Crash::Adapter { name, .. } => format!(
"The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
),
gpu::Crash::Listing => format!(
"The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
),
});
}
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
@@ -855,7 +925,9 @@ fn main() -> std::process::ExitCode {
// The first frame is due from now (`gpu::watch_first_frame`).
#[cfg(feature = "wgpu")]
created.ready();
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
}),
)
});
+174
View File
@@ -0,0 +1,174 @@
//! Keeps the custom title bar's undecorated window truly borderless on Windows (#1758).
//!
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
//! desktop compositor still reserves that frame for the window surface: the content is drawn
//! offset by the caption and border while the pointer keeps the un-offset client coordinates, so
//! it no longer points at what is drawn under it (rust-windowing/winit#2419; PhotoCraft fixed the
//! same in storytold/photocraft#2401). [`service`] strips the two styles and has Windows
//! recalculate the frame; winit puts them back whenever it changes the window flags (maximize,
//! restore, …), so the returned guard runs every frame.
//!
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included, and
//! the shell reads a caption-less window covering its monitor as a full-screen app (#1977). The
//! guard therefore also trims a parked maximize's rect back to the work area plus its own frame,
//! clear of the taskbar, as PhotoCraft does (storytold/photocraft#3308). winit pins the client
//! area to the work area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
/// Run once per frame; a no-op once the window is borderless.
pub type WindowFrameFn = Box<dyn FnMut()>;
pub use platform::service;
#[cfg(all(windows, feature = "wgpu"))]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
/// A guard that keeps the window borderless each frame, or `None` with the system title bar
/// (the window is decorated) or when its handle can't be found.
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<super::WindowFrameFn> {
if !custom_titlebar {
return None;
}
let target = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(h) => h.hwnd.get(),
_ => return None,
};
let hwnd = find_window(target)?;
reapply(&hwnd);
Some(Box::new(move || reapply(&hwnd)))
}
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
/// maximize stays clear of the taskbar.
fn reapply(hwnd: &HWND) {
strip(hwnd);
keep_clear_of_the_taskbar(hwnd);
}
/// The top-level window with the given raw handle: `EnumWindows` hands out real `HWND`s, so
/// this needs no `unsafe` to wrap a raw one.
fn find_window(target: isize) -> Option<HWND> {
let mut found = None;
let _ = winsafe::EnumWindows(|hwnd| {
if hwnd.ptr() as isize == target {
found = Some(hwnd);
false
} else {
true
}
});
found
}
/// Removes `WS_CAPTION` (which includes `WS_BORDER`) and has Windows recalculate the frame;
/// the sizing border stays, so edge resizing and snapping keep working.
fn strip(hwnd: &HWND) {
let style = hwnd.style();
let without = style & !(co::WS::CAPTION | co::WS::BORDER);
if without.raw() == style.raw() {
return;
}
hwnd.set_style(without);
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// [`super::clamped_height`] for this frame's window: trim a maximized window whose rect runs
/// under the taskbar back to the work area plus its frame. No-op otherwise.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
}
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some(height) =
super::clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
else {
return;
};
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
}
#[cfg(not(all(windows, feature = "wgpu")))]
mod platform {
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<super::WindowFrameFn> {
None
}
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize border
/// (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not parked but
/// being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it already
/// does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at its maximize
/// place. `monitor` is the monitor's full rect and `work` its work area (the monitor less the
/// taskbar) in the same coordinates. The parked check is against the full monitor, because that is
/// where Windows puts a caption-less maximize; the trim is against the work area. Platform-neutral
/// so it is tested everywhere.
#[cfg_attr(not(all(windows, feature = "wgpu")), allow(dead_code))]
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
let (l, t, r, b) = rect;
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
return None;
}
if b <= wb + side {
return None;
}
Some(wb + side - t)
}
#[cfg(test)]
mod tests {
use super::clamped_height;
#[test]
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 px taskbar: a caption-less maximize covers the whole monitor,
// taskbar included (#1977). Trimmed so the rect ends at the work area plus the frame the
// window was parked with.
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
}
#[test]
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
}
#[test]
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom is
// still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_away_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
}
}
+63
View File
@@ -0,0 +1,63 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Runs the desktop binary without a display, so a regression fails instead of opening a window.
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
let mut child = Command::new(BIN)
.args(args)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env("VECTORCRAFT_NO_PREFS", "1")
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while child.try_wait().unwrap().is_none() {
if Instant::now() >= deadline {
child.kill().unwrap();
panic!("{args:?}: the app started instead of printing its usage");
}
std::thread::sleep(Duration::from_millis(10));
}
child.wait_with_output().unwrap()
}
fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
let out = run(args, env);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
}
// Headless work is the CLI's: the usage says where to go.
assert!(stdout.contains("vectorcraft-cli"), "{args:?}: {stdout}");
}
#[test]
fn help_prints_usage_and_exits_before_startup() {
prints_usage(&["--help"], &[]);
prints_usage(&["-h"], &[]);
// After a file or a flag, too: nothing is opened.
prints_usage(&["drawing.svg", "--help"], &[]);
prints_usage(&["--in-window-menus", "-h"], &[]);
}
#[test]
fn help_wins_over_a_bad_control_port_variable() {
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5v14M20 5v14"/><path d="M4 12h16"/><path d="M7.5 9 4.5 12l3 3M16.5 9l3 3-3 3"/></svg>

After

Width:  |  Height:  |  Size: 281 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 5H20L14 12.5V18.5L10 20V12.5Z" stroke-width="1.5" /></svg>

After

Width:  |  Height:  |  Size: 253 B

+22
View File
@@ -93,3 +93,25 @@ sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
sync_github_name = false
[people.lewandowskit]
real_name = "Tomasz Lewandowski"
display_name = "Tomasz Lewandowski"
sync_github_name = false
[people.osamaamin2011]
real_name = "Osama Amin"
display_name = "Osama Amin"
sync_github_name = false
[people.giang17]
real_name = "Giang Nguyen"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.limackk]
real_name = "Kamil Kwiatkowski"
display_name = "Kamil Kwiatkowski"
sync_github_name = false
+1 -1
View File
@@ -17,7 +17,7 @@ synth = []
crc32fast = { workspace = true }
flate2 = { workspace = true }
image = { workspace = true }
ruzstd = "0.9"
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+15 -2
View File
@@ -296,12 +296,23 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
read_within(bytes, limits, MAX_LAYER_PIXELS)
}
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
let mut reader = Reader::new(&s, &mut archive);
reader.pixels_left = pixels;
reader.document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -311,11 +322,13 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels_left: u64,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
+43 -1
View File
@@ -105,10 +105,17 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
let pixels = u64::from(w) * u64::from(h);
if pixels > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let Some(left) = r.pixels_left.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
r.pixels_left = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
@@ -404,6 +411,41 @@ mod tests {
}
}
/// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile
/// of 0xFF). Written by hand for the reader, like [`fixture`].
fn flat_layers(count: u32, side: i32) -> Vec<u8> {
let layer = |i: u32| {
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))];
for c in 1..=4 {
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2])));
}
F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))])
};
let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
synth::container(&[("doc.dat", &doc, Method::Zlib)], None)
}
/// #1704: the pixels of all pixel layers together are bounded, also when the archive holds
/// no data for them: the layers past the allowance are left out with a note.
#[test]
fn pixel_layers_share_one_allowance_for_the_whole_document() {
let file = flat_layers(5, 16);
let layers = |pixels: u64| {
let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap();
let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count();
(n, doc.warnings)
};
assert_eq!(layers(5 * 256), (5, vec![]));
let (kept, warnings) = layers(3 * 256 + 255);
assert_eq!(kept, 3);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}");
assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5);
}
#[test]
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
let bytes = png();
+89 -5
View File
@@ -16,6 +16,7 @@ mod defaults;
pub mod track;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 {
(0.02 * scale).clamp(0.01, 0.25)
}
/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their
/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the
/// caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id
/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush,
/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]).
/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
@@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}
leave_out_brushed(doc, &mut out, &mut Known::new());
out
}
/// A symbol (`false`) or pattern (`true`) of a document, by name: what art draws by name.
type Named<'a> = (bool, &'a str);
/// What [`draws_brushed`] found of each symbol and pattern so far.
type Known = HashMap<(bool, String), bool>;
/// The symbol an instance draws and the patterns that paint `n` (not its children).
fn named(n: &Node) -> impl Iterator<Item = Named<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in
/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far.
fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
let key = (def.0, def.1.to_string());
if let Some(&answer) = known.get(&key) {
return answer;
}
let art = |(pattern, name): Named<'_>| -> Vec<&Node> {
match pattern {
false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(),
true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(),
}
};
// Without recursion, each one looked at once: they may use one another without end.
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
let mut answer = false;
while let Some(n) = stack.pop() {
if has_brush(n) {
answer = true;
break;
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
for other in named(n) {
if seen.insert((other.0, other.1.to_string())) {
stack.extend(art(other));
}
}
}
known.insert(key, answer);
answer
}
/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke
/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or
/// pattern holding art stroked with the brush itself would draw the brush again, without end.
fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
fn walk(doc: &Document, n: &mut Node, known: &mut Known) {
for paint in vectorcraft_doc::swatches::node_paints_mut(n) {
if let Paint::Pattern { pattern, .. } = paint
&& draws_brushed(doc, (true, pattern.as_str()), known)
{
*paint = Paint::None;
}
}
if let Some(children) = n.children_mut() {
children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for c in children.iter_mut() {
walk(doc, Arc::make_mut(c), known);
}
}
if n.extras().mask.is_some()
&& let Some(mask) = n.extras_mut().mask.as_mut()
{
walk(doc, Arc::make_mut(&mut mask.art), known);
}
}
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for n in nodes.iter_mut() {
walk(doc, n, known);
}
}
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
@@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
continue;
}
any = true;
for mut p in stroke_pieces(b, &bp, st) {
for mut p in stroke_pieces(doc, b, &bp, st) {
p.opacity *= st.opacity;
if st.blend != vectorcraft_color::BlendMode::Normal {
p.blend = st.blend;
+90 -38
View File
@@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec<Point>) {
#[test]
fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 1);
let b = bounds(&out);
assert!(near(b.height(), 10.0, 0.05), "{b:?}");
@@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
#[test]
fn calligraphic_flat_nib_is_thin_along_its_angle() {
let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out));
}
#[test]
fn calligraphic_width_at_45_degrees_matches_ellipse_support() {
let (a, b) = (5.0_f64, 1.0_f64);
let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt();
assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height());
// A vertical stroke with the same nib has the same width (45° is symmetric).
let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
assert!(near(bounds(&v).width(), expect, 0.05));
}
#[test]
fn calligraphic_scales_with_stroke_weight() {
let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
assert!(near(bounds(&out).height(), 6.0, 0.05));
}
#[test]
fn round_nib_is_direction_independent() {
let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let b = bounds(&out);
// Round caps: the bounds grow by the radius on every side.
assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}");
@@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() {
#[test]
fn closed_calligraphic_makes_a_ring() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let pd = out[0].path_data().unwrap();
assert_eq!(pd.subpaths.len(), 2);
let poly_area = |sp: &vectorcraft_geom::SubPath| {
@@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node {
#[test]
fn art_brush_stretches_to_path_length() {
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let b = bounds(&out);
assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}");
assert!(near(b.height(), 4.0, 1e-6));
// 3 pt stroke → 3× wider.
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
assert!(near(bounds(&out).height(), 12.0, 1e-6));
}
@@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() {
let mut spacer = frame.clone();
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let g = &out[0];
let m = g.children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}");
@@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() {
#[test]
fn art_brush_proportional_keeps_aspect() {
let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
@@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() {
Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let shaft = rect_art(80.0, 2.0);
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]);
let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let out = stroke_pieces(
&Document::new(1.0, 1.0),
&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }),
&line(0.0, 0.0, 300.0, 0.0),
&stroke(1.0),
);
let h = out[0].children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
}
@@ -154,7 +162,7 @@ fn art_brush_follows_curves() {
bp_open.push(*el);
}
}
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let mut pts = vec![];
all_points(&out[0], &mut pts);
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
@@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush {
#[test]
fn scatter_count_and_spacing() {
let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 10);
let centres: Vec<f64> = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect();
for (i, c) in centres.iter().enumerate() {
assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}");
}
let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 5);
}
@@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() {
}),
};
let p = line(0.0, 0.0, 300.0, 0.0);
let a1 = stroke_pieces(&b, &p, &stroke(1.0));
let a2 = stroke_pieces(&b, &p, &stroke(1.0));
let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
assert_eq!(a1, a2);
let ys: Vec<f64> = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect();
assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}");
@@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush {
#[test]
fn pattern_tile_count_by_fit() {
let p = line(0.0, 0.0, 100.0, 0.0);
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
let p2 = line(0.0, 0.0, 104.0, 0.0);
let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
assert_eq!(st.len(), 10);
assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly");
let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
assert_eq!(sp.len(), 10);
assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size");
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
}
#[test]
fn pattern_places_corner_tiles() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let corners = out
.iter()
.filter(
@@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() {
#[test]
fn bristle_makes_translucent_strands() {
let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(out.len() >= 5, "{}", out.len());
assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0));
let bb = bounds(&out);
@@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() {
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
a.colorization = Colorization::Tints;
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let mut white = rect_art(1.0, 1.0);
white.appearance.set_fill(Paint::solid(Color::WHITE));
@@ -310,7 +318,7 @@ fn brushes_serde_round_trip() {
fn every_default_brush_draws_something() {
let bp = preview_path(120.0, 24.0);
for b in defaults() {
let out = stroke_pieces(b, &bp, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0));
assert!(!out.is_empty(), "{}", b.name);
let bb = bounds(&out);
assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name);
@@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
@@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() {
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
@@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() {
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
@@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() {
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0))
);
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
);
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
@@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
@@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
#[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() };
let heights = |b: &Brush, st: &StrokeLayer| -> Vec<f64> {
stroke_pieces(&Document::new(1.0, 1.0), 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).
@@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() {
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_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0))
);
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
@@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() {
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
#[test]
fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
use vectorcraft_doc::{PatternDef, Symbol};
let mut doc = Document::new(100.0, 100.0);
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
let painted = |pattern: &str| {
let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY };
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0))
};
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(!out.is_empty());
for piece in &out {
let children = piece.children().unwrap();
let symbols: Vec<&str> = children
.iter()
.filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None })
.collect();
assert_eq!(symbols, ["plain"]);
let fills: Vec<Paint> =
children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect();
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
}
}
+2
View File
@@ -21,3 +21,5 @@ image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
+1
View File
@@ -214,6 +214,7 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
warnings.extend(damaged);
document.limit_depth_noting(&mut warnings);
Ok(Imported { document, warnings })
}
+21
View File
@@ -651,6 +651,27 @@ fn inserts_become_symbol_instances_with_their_transform() {
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
}
#[test]
fn an_insert_is_measured_clicked_and_marqueed_by_its_blocks_art() {
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let [inst] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
// The line, 20 units up from the insertion point: a box with no width at x 100. The 20 × 20
// square placed by the insert's transform would be a 60 × 40 box from x 70 to 130.
let b = doc.bounds_of(&[inst.id], false).unwrap();
assert!(close(b, Rect::new(100.0, h - 70.0, 100.0, h - 50.0), 1e-9), "{b:?}");
// A click 25 pt right of the line, inside that square's box, selects nothing; one on the line
// selects the insert.
let hit = |x: f64| vectorcraft_doc::hit::hit_test(&doc, Point::new(x, h - 60.0), vectorcraft_doc::hit::HitOptions::default()).map(|h| h.leaf);
assert_eq!(hit(125.0), None);
assert_eq!(hit(100.0), Some(inst.id));
// A marquee beside the line leaves it out; one across it takes it.
let marquee = |r: Rect| vectorcraft_doc::hit::marquee(&doc, r, None, false);
assert!(marquee(Rect::new(115.0, h - 65.0, 125.0, h - 55.0)).is_empty());
assert_eq!(marquee(Rect::new(90.0, h - 62.0, 110.0, h - 58.0)), vec![inst.id]);
}
#[test]
fn by_block_colours_make_a_symbol_per_look() {
// The block's line takes the insert's colour: red and green make two symbols; a third red
+13 -4
View File
@@ -8,7 +8,7 @@ use std::io::Cursor;
use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
@@ -158,6 +158,9 @@ struct LayerFlags {
pub(crate) struct Scene<'a> {
doc: &'a Document,
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
/// crops to the region ([`SymbolBoxes::quick`]).
symbols: SymbolBoxes<'a>,
o: &'a DxfOptions,
/// Document space → drawing units (y up, origin at the region's bottom-left).
to_dxf: Affine,
@@ -192,6 +195,7 @@ impl<'a> Scene<'a> {
let r = o.region;
Self {
doc,
symbols: doc.symbol_boxes().quick(),
o,
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
k,
@@ -271,7 +275,7 @@ impl<'a> Scene<'a> {
return;
}
if self.o.crop && !layer {
match n.visual_bounds() {
match n.visual_bounds_with(&self.symbols) {
Some(b) if !overlaps(b, self.o.region) => return,
None if !n.is_container() => return,
_ => {}
@@ -316,7 +320,12 @@ impl<'a> Scene<'a> {
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
@@ -502,7 +511,7 @@ impl<'a> Scene<'a> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
}
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
+23
View File
@@ -125,6 +125,18 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
assert_eq!(DxfVersion::from_id("2019"), None);
}
#[test]
fn r12_viewport_has_no_duplicate_group_codes() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let vport = table(&out, "VPORT").into_iter().find(|o| kind(o) == "VPORT").expect("VPORT table record");
let codes: Vec<i32> = vport.iter().map(|pair| pair.0).collect();
let unique: std::collections::HashSet<_> = codes.iter().collect();
assert_eq!(unique.len(), codes.len(), "R12 VPORT record has duplicate group codes: {vport:?}");
let z = vport.iter().find(|pair| pair.0 == 36).map(|pair| pair.1.trim().parse::<f64>().unwrap());
assert_eq!(z, Some(1.0), "the view direction looks down the z axis");
}
#[test]
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let d = shapes_doc();
@@ -442,3 +454,14 @@ fn crop_leaves_out_art_outside_the_region() {
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
#[test]
fn crop_keeps_an_instance_whose_art_reaches_the_region() {
// The symbol's red rectangle, (100, 50) to (140, 90), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let o = DxfOptions { crop: true, ..opts(&d) };
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
+2 -1
View File
@@ -441,7 +441,8 @@ impl Drawing {
c.xy(13, 0.0, 0.0);
c.xy(14, 1.0, 1.0);
c.xy(15, 10.0, 10.0);
c.xyz(16, 0.0, 0.0);
// The view direction looks down the z axis: (0, 0, 1), each code once.
c.xy(16, 0.0, 0.0);
c.num(36, 1.0);
c.xyz(17, 0.0, 0.0);
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
+50 -1
View File
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
if let Some(date) = bytes.get_mut(24..36) {
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
Ok(bytes)
}
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
fn aligned(icc: Vec<u8>) -> Vec<u8> {
const TABLE: usize = 128;
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
let data_start = TABLE + 4 + 12 * n;
let mut entries = Vec::with_capacity(n);
for i in 0..n {
let e = TABLE + 4 + 12 * i;
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
return icc;
}
entries.push((off, size));
}
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
return icc;
}
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| entries[i].0);
for i in order {
let (off, size) = entries[i];
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
Some((_, at)) => *at,
None => {
out.resize(out.len().next_multiple_of(4), 0);
let at = out.len();
out.extend_from_slice(&icc[off..off + size]);
moved.push(((off, size), at));
at
}
};
let e = TABLE + 4 + 12 * i + 4;
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
}
out.resize(out.len().next_multiple_of(4), 0);
let len = out.len() as u32;
out[0..4].copy_from_slice(&len.to_be_bytes());
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
if let Some(id) = out.get_mut(84..100) {
id.fill(0);
}
out
}
fn text_tag(s: &str) -> Option<ProfileText> {
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
}
+116 -1
View File
@@ -13,7 +13,7 @@ pub mod generic;
pub mod icc;
pub mod lab;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use serde::{Deserialize, Serialize};
@@ -165,6 +165,8 @@ fn generic() -> Arc<GenericCmyk> {
}
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
/// Counts the profiles [`register_icc`] has registered ([`profiles_generation`]).
static PROFILES_GENERATION: AtomicU64 = AtomicU64::new(0);
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
@@ -251,9 +253,17 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
u.push(Arc::new(p));
drop(u);
clear_proof_cache();
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
Ok(info)
}
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
/// as long as the number stays the same.
pub fn profiles_generation() -> u64 {
PROFILES_GENERATION.load(Ordering::Relaxed)
}
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
@@ -505,6 +515,7 @@ impl Cms {
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
ProofTarget::Tritanopia => TRITANOPIA.simulate(srgb),
ProofTarget::WorkingCmyk => {
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
@@ -572,6 +583,106 @@ fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
}
// Tritanopia: Brettel, Viénot & Mollon (1997), "Computerized simulation of color appearance for
// dichromats", JOSA A 14(10), 2647-2655, https://doi.org/10.1364/JOSAA.14.002647. A color keeps its
// L and M cone responses, and its S response is replaced so that it lies on one of two half-planes in
// LMS space. Both half-planes contain the neutral axis (black to the display's white, so grays keep
// their value); one passes through the 485 nm spectral color and the other through the 660 nm one. A
// color uses the half-plane on its own side of the plane through the neutral axis and the S axis.
// The matrices are computed at compile time from these inputs:
// - linear sRGB → CIE XYZ: IEC 61966-2-1 (D65);
// - CIE XYZ → LMS: Smith & Pokorny (1975), as printed in https://daltonlens.org/understanding-cvd-simulation/;
// - the 485 nm and 660 nm colors: the Judd-Vos color matching functions,
// http://www.cvrl.org/database/data/cmfs/ciexyzjv.csv.
const LINEAR_SRGB_TO_XYZ: [[f64; 3]; 3] = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
const XYZ_TO_LMS: [[f64; 3]; 3] = [[0.155_14, 0.543_12, -0.032_86], [-0.155_14, 0.456_84, 0.032_86], [0.0, 0.0, 0.016_08]];
const XYZ_485_NM: [f64; 3] = [0.056_985, 0.169_87, 0.586_4];
const XYZ_660_NM: [f64; 3] = [0.161_61, 0.061, 0.000_011_906];
const TRITANOPIA: Dichromat = Dichromat::tritanope();
/// A dichromat simulation with two half-planes, as matrices on linear sRGB. `near` applies to colors
/// whose dot product with `side` is 0 or more, and `far` applies to the others.
struct Dichromat {
side: [f32; 3],
near: [[f32; 3]; 3],
far: [[f32; 3]; 3],
}
impl Dichromat {
const fn tritanope() -> Self {
let to_lms = mat3_mul(&XYZ_TO_LMS, &LINEAR_SRGB_TO_XYZ);
let to_rgb = mat3_inverse(&to_lms);
let white = mat3_apply(&to_lms, [1.0; 3]);
let a485 = mat3_apply(&XYZ_TO_LMS, XYZ_485_NM);
let a660 = mat3_apply(&XYZ_TO_LMS, XYZ_660_NM);
let mut side = cross(white, [0.0, 0.0, 1.0]);
if dot(side, a485) < 0.0 {
side = [-side[0], -side[1], -side[2]];
}
// A color on the 485 nm side lands on that half-plane.
let near = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a485)), &to_lms));
let far = mat3_mul(&to_rgb, &mat3_mul(&keep_lm_on_plane(cross(white, a660)), &to_lms));
// `side · lms(rgb)` is `(to_lmsᵀ · side) · rgb`.
let t = &to_lms;
let side_rgb = [
t[0][0] * side[0] + t[1][0] * side[1] + t[2][0] * side[2],
t[0][1] * side[0] + t[1][1] * side[1] + t[2][1] * side[2],
t[0][2] * side[0] + t[1][2] * side[1] + t[2][2] * side[2],
];
Dichromat { side: vec3_f32(side_rgb), near: mat3_f32(&near), far: mat3_f32(&far) }
}
fn simulate(&self, srgb: [f32; 3]) -> [f32; 3] {
let rgb = lab::srgb_linear(srgb);
let s = self.side;
let m = if s[0] * rgb[0] + s[1] * rgb[1] + s[2] * rgb[2] >= 0.0 { &self.near } else { &self.far };
lab::mat_mul(m, rgb).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
}
}
/// LMS → LMS: keeps L and M and sets S so the result lies in the plane with normal `n`.
const fn keep_lm_on_plane(n: [f64; 3]) -> [[f64; 3]; 3] {
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-n[0] / n[2], -n[1] / n[2], 0.0]]
}
const fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
const fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
const fn mat3_apply(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[dot(m[0], v), dot(m[1], v), dot(m[2], v)]
}
const fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut out = [[0.0; 3]; 3];
let mut i = 0;
while i < 3 {
out[i] = [dot(a[i], [b[0][0], b[1][0], b[2][0]]), dot(a[i], [b[0][1], b[1][1], b[2][1]]), dot(a[i], [b[0][2], b[1][2], b[2][2]])];
i += 1;
}
out
}
const fn mat3_inverse(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let c0 = cross(m[1], m[2]);
let c1 = cross(m[2], m[0]);
let c2 = cross(m[0], m[1]);
let det = dot(m[0], c0);
[[c0[0] / det, c1[0] / det, c2[0] / det], [c0[1] / det, c1[1] / det, c2[1] / det], [c0[2] / det, c1[2] / det, c2[2] / det]]
}
const fn vec3_f32(v: [f64; 3]) -> [f32; 3] {
[v[0] as f32, v[1] as f32, v[2] as f32]
}
const fn mat3_f32(m: &[[f64; 3]; 3]) -> [[f32; 3]; 3] {
[vec3_f32(m[0]), vec3_f32(m[1]), vec3_f32(m[2])]
}
/// View → Proof Setup target.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
@@ -591,6 +702,8 @@ pub enum ProofTarget {
Protanopia,
/// Colour blindness — deuteranopia type.
Deuteranopia,
/// Color blindness, tritanopia type.
Tritanopia,
}
impl ProofTarget {
@@ -605,6 +718,7 @@ impl ProofTarget {
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
"protanopia" => ProofTarget::Protanopia,
"deuteranopia" => ProofTarget::Deuteranopia,
"tritanopia" => ProofTarget::Tritanopia,
_ => return None,
})
}
@@ -617,6 +731,7 @@ impl ProofTarget {
ProofTarget::MonitorRgb => "monitorRgb".into(),
ProofTarget::Protanopia => "protanopia".into(),
ProofTarget::Deuteranopia => "deuteranopia".into(),
ProofTarget::Tritanopia => "tritanopia".into(),
}
}
/// Whether the target simulates a CMYK press (separations make sense).
+29
View File
@@ -194,6 +194,35 @@ fn colour_blindness_proofs() {
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
}
/// The expected colors were computed separately with NumPy from the same published inputs (linear
/// sRGB to XYZ from IEC 61966-2-1, Smith & Pokorny's XYZ to LMS, Judd-Vos XYZ at 485 nm and 660 nm),
/// following Brettel, Viénot & Mollon (1997) with the display's white as the neutral axis.
#[test]
fn tritanopia_proof_follows_brettel_1997() {
let c = cms();
let t = ProofSetup { target: ProofTarget::Tritanopia, ..Default::default() };
assert_eq!(ProofTarget::parse("tritanopia"), Some(ProofTarget::Tritanopia));
assert_eq!(ProofTarget::Tritanopia.id(), "tritanopia");
for g in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = c.proof_srgb([g; 3], &t);
assert!(out.iter().all(|v| (v - g).abs() < 1e-4), "gray {g} stays gray: {out:?}");
}
let cases = [
// The 660 nm half-plane.
([1.0, 0.0, 0.0], [1.0, 0.0, 0.299_231]),
([1.0, 0.0, 1.0], [0.934_206, 0.388_351, 0.467_178]),
([0.9, 0.6, 0.1], [0.926_825, 0.557_667, 0.599_649]),
// The 485 nm half-plane.
([0.0, 0.0, 1.0], [0.0, 0.377_268, 0.519_933]),
([0.0, 1.0, 0.0], [0.480_503, 0.920_568, 1.0]),
([0.2, 0.4, 0.8], [0.0, 0.464_73, 0.563_516]),
];
for (rgb, want) in cases {
let got = c.proof_srgb(rgb, &t);
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rgb:?}: {got:?}, expected {want:?}");
}
}
#[test]
fn bad_icc_rejected() {
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
+23
View File
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
assert_eq!(abs(true), abs(false));
}
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
#[test]
fn written_profiles_align_their_tags() {
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
let icc = super::icc_bytes(name).unwrap();
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
assert_eq!(be(0), icc.len(), "{name}: header size");
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
let n = be(128);
for i in 0..n {
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
assert!(off + size <= icc.len());
}
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
let orig = moxcms::ColorProfile::new_srgb();
if name == super::SRGB {
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
}
}
}
+17
View File
@@ -282,6 +282,23 @@ impl RowMap {
self.key
}
/// The tint (0..1) of the new colour current colour `c` takes, when it is a tint of the new
/// colour: what a colour linked to a global or spot swatch keeps as its tint. `None` when the
/// method makes it something else (a shade, or another hue).
pub fn tint(&self, c: Color) -> Option<f32> {
match self.method {
Method::Exact => Some(1.0),
Method::PreserveTints if is_tint(c, self.key) => Some(tint_of(c, self.key)),
Method::PreserveTints => Some(1.0),
Method::ScaleTints => Some(tint_of(c, self.key)),
Method::TintsShades => {
let d = c.to_lab().l - self.mean_l;
(d >= 0.0).then(|| 1.0 - d / (100.0 - self.mean_l).max(1e-3))
}
Method::HueShift => (c == self.key).then_some(1.0),
}
}
/// The new colour of current colour `c`, in `c`'s model.
pub fn map(&self, c: Color) -> Color {
let to = keep_model(c, self.to);
+4 -2
View File
@@ -24,8 +24,10 @@ pub struct Clip {
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
/// for each): deeper ones are left out.
pub const MAX_NEST: usize = 128;
/// for each): deeper ones are left out. As deep as a document's objects may nest
/// ([`crate::MAX_DEPTH`]) under a layer, around the art; clips in groups that deep together are
/// merged by [`Document::limit_depth`].
pub const MAX_NEST: usize = crate::MAX_DEPTH - 2;
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
pub fn deep_clips() -> String {
+588
View File
@@ -0,0 +1,588 @@
//! How deep objects may nest.
//!
//! The tree is walked by recursion everywhere (drawing, bounds, hit tests, saving, exporting), so
//! a tree of unbounded depth overflows the stack: an abort no guard catches. No document deeper
//! than [`MAX_DEPTH`] is ever kept, nor one whose symbols and patterns draw deeper than
//! [`MAX_DRAWN_DEPTH`] (a symbol holding an instance of itself, or a pattern painting its own
//! tile, draws without end): importers and the native reader merge deeper objects into the group
//! around them and leave such instances and paints out
//! ([`Document::limit_depth`], with [`merged_note`] and [`symbols_note`] as their warnings), and
//! the engine refuses an edit that would nest deeper ([`Document::too_deep`]).
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_color::Paint;
use crate::{Document, Node, NodeKind};
/// The deepest an object may be: a top-level layer is 1 deep, what it holds 2, and so on (the art
/// of an opacity mask is one deeper than the object it masks). A symbol's or pattern's art counts
/// from 1 again.
///
/// A native file writes three levels of JSON for each level of objects, and its reader stops at
/// 128 levels: 32 leaves the deepest object 29 of them for its own data, so every document that
/// can be made can be saved and opened again.
pub const MAX_DEPTH: usize = 32;
/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its
/// instance and a pattern's tile under the object it paints (and what those use, under them).
/// Symbols a few objects deep nest in one another dozens deep within it.
pub const MAX_DRAWN_DEPTH: usize = 4 * MAX_DEPTH;
/// The warning of a file whose objects were nested deeper than [`MAX_DEPTH`].
pub fn merged_note() -> String {
format!("objects nested more than {MAX_DEPTH} deep were merged into the group around them")
}
/// The warning of a file with symbols or patterns that use themselves or are drawn deeper than
/// [`MAX_DRAWN_DEPTH`].
pub fn symbols_note() -> String {
format!(
"symbol instances and pattern paints inside their own symbol or pattern, or drawn more than {MAX_DRAWN_DEPTH} objects deep, were left out"
)
}
/// Run `f` where recursion has room: on a thread of its own with a 16 MB stack (the main thread
/// has 1 MB on Windows), or here where there are no threads (the web) or none can be started. For
/// readers whose recursion is capped but heavy; a panic in `f` goes on to the caller.
pub fn with_deep_stack<T: Send>(f: impl Fn() -> T + Sync) -> T {
#[cfg(not(target_arch = "wasm32"))]
{
let threaded = std::thread::scope(|s| {
let thread = std::thread::Builder::new().stack_size(16 << 20).spawn_scoped(s, &f).ok()?;
Some(thread.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
});
if let Some(done) = threaded {
return done;
}
}
f()
}
/// Is anything in `now` (each `depth` deep) deeper than [`MAX_DEPTH`], or `over` (given its
/// depth)? Without recursion: the trees may be of any depth. With `before` (the same list before
/// an edit, within the caps), only what the edit changed is looked at: a subtree still shared with
/// it, in the same place, is as it was.
fn deeper(now: &[Arc<Node>], before: Option<&[Arc<Node>]>, depth: usize, over: &dyn Fn(&Node, usize) -> bool) -> bool {
type Pending<'a> = Vec<(&'a Node, Option<&'a Node>, usize)>;
fn push<'a>(stack: &mut Pending<'a>, now: &'a [Arc<Node>], before: Option<&'a [Arc<Node>]>, depth: usize) {
for (i, n) in now.iter().enumerate() {
let was = before.and_then(|b| b.get(i));
if !was.is_some_and(|w| Arc::ptr_eq(w, n)) {
stack.push((n, was.map(|w| &**w), depth));
}
}
}
let mut stack = vec![];
push(&mut stack, now, before, depth);
while let Some((n, was, d)) = stack.pop() {
if d > MAX_DEPTH || over(n, d) {
return true;
}
if let Some(ch) = n.children() {
push(&mut stack, ch, was.and_then(Node::children).map(Vec::as_slice), d + 1);
}
if let Some(m) = &n.extras().mask {
let was = was.and_then(|w| w.extras().mask.as_ref()).map(|m| std::slice::from_ref(&m.art));
push(&mut stack, std::slice::from_ref(&m.art), was, d + 1);
}
}
false
}
/// [`deeper`] for the nesting alone.
fn nested_deeper(now: &[Arc<Node>], depth: usize) -> bool {
deeper(now, None, depth, &|_, _| false)
}
/// Merge what is deeper than [`MAX_DEPTH`] in `nodes` (each `depth` deep) into the group around
/// it: a container at the cap is replaced by what it holds (a clipping group's clipping path and
/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns
/// whether anything changed. Recurses no deeper than the cap.
fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
let mut changed = false;
let mut i = 0;
while let Some(n) = nodes.get_mut(i) {
if !nested_deeper(std::slice::from_ref(n), depth) {
i += 1;
continue;
}
changed = true;
let n = Arc::make_mut(n);
if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) {
n.extras_mut().mask = None;
}
if depth < MAX_DEPTH {
if let Some(ch) = n.children_mut() {
limit(ch, depth + 1);
}
i += 1;
continue;
}
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
let (visible, locked) = (n.visible, n.locked);
let mut held = n.children_mut().map(std::mem::take).unwrap_or_default();
if clip && !held.is_empty() {
held.remove(0);
}
for c in &mut held {
if (!visible && c.visible) || (locked && !c.locked) {
let c = Arc::make_mut(c);
c.visible &= visible;
c.locked |= locked;
}
}
// What it held takes its place, and is looked at in its turn.
nodes.splice(i..=i, held);
}
changed
}
/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an
/// instance its symbol's art and a pattern paint its tile's.
type Def<'a> = (bool, &'a str);
/// How deep each symbol's and pattern's art is drawn, the art of the symbols and patterns it uses
/// included (drawn under the object that uses them): `None` for one that uses itself, in its art
/// or through what that uses, or is drawn deeper than [`MAX_DRAWN_DEPTH`].
type Heights<'a> = HashMap<Def<'a>, Option<usize>>;
/// What `n` itself draws by name: the symbol it is an instance of, the patterns that paint it.
fn uses(n: &Node) -> impl Iterator<Item = Def<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = crate::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// [`Heights`] of the document's symbols and patterns. Without recursion: they may use one
/// another in a chain of any length. Their own art is within [`MAX_DEPTH`].
fn heights(doc: &Document) -> Heights<'_> {
// Each one's own depth, and what its art uses with the deepest use of each.
type Own<'a> = (usize, Vec<(Def<'a>, usize)>);
let arts = (doc.symbols.iter().map(|s| ((false, s.name.as_str()), std::slice::from_ref(&s.art))))
.chain(doc.patterns.iter().map(|p| ((true, p.name.as_str()), &p.art[..])));
let mut own: HashMap<Def, Own> = HashMap::new();
for (def, art) in arts {
let (mut deepest, mut used) = (0, HashMap::<Def, usize>::new());
let mut stack: Vec<(&Node, usize)> = art.iter().map(|n| (&**n, 1)).collect();
while let Some((n, d)) = stack.pop() {
deepest = deepest.max(d);
for u in uses(n) {
let at = used.entry(u).or_default();
*at = (*at).max(d);
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1)));
}
// (The first of two of one name is the one drawn.)
own.entry(def).or_insert((deepest, used.into_iter().collect()));
}
// Depth first over what uses what. One met again while it is being worked out (`None` in its
// entry) uses itself.
let mut found: HashMap<Def, Option<Option<usize>>> = HashMap::new();
let starts: Vec<Def> = own.keys().copied().collect();
for start in starts {
if found.contains_key(&start) {
continue;
}
// (which, the next of its uses to look at, the deepest found so far: `None` once it can't be drawn)
let mut path: Vec<(Def, usize, Option<usize>)> = vec![(start, 0, own.get(&start).map(|o| o.0))];
found.insert(start, None);
while let Some(&(def, next, deepest)) = path.last() {
let used = own.get(&def).map_or(&[][..], |o| &o.1);
let Some(&(other, at)) = used.get(next) else {
// Everything it uses is worked out.
let deepest = deepest.filter(|d| *d <= MAX_DRAWN_DEPTH);
found.insert(def, Some(deepest));
path.pop();
if let Some((user, next, held)) = path.last_mut() {
let at = own.get(user).and_then(|o| o.1.get(next.wrapping_sub(1))).map_or(1, |u| u.1);
*held = held.zip(deepest).map(|(h, d)| h.max(at + d));
}
continue;
};
let known = found.get(&other).copied();
if known.is_none() && own.contains_key(&other) {
found.insert(other, None);
}
let Some((_, next, deepest)) = path.last_mut() else { break };
*next += 1;
match known {
// Not in the document: it draws nothing.
None if !own.contains_key(&other) => {}
None => path.push((other, 0, own.get(&other).map(|o| o.0))),
Some(Some(Some(d))) => *deepest = deepest.map(|h| h.max(at + d)),
// It uses itself (it is on the way down here), or can't be drawn.
Some(None | Some(None)) => *deepest = None,
}
}
}
found.into_iter().map(|(def, h)| (def, h.flatten())).collect()
}
/// Can't `used`, by an object `depth` deep, be drawn there?
fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool {
heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH))
}
/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object):
/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees
/// within [`MAX_DEPTH`].
fn strip(nodes: &mut Vec<Arc<Node>>, depth: usize, bad: &dyn Fn(Def, usize) -> bool) -> bool {
let any = |n: &Node, d: usize| uses(n).any(|u| bad(u, d));
let before = nodes.len();
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if bad((false, symbol.as_str()), depth)));
let mut changed = nodes.len() != before;
for n in nodes.iter_mut() {
if !deeper(std::slice::from_ref(n), None, depth, &any) {
continue;
}
changed = true;
let n = Arc::make_mut(n);
if uses(n).any(|u| bad(u, depth)) {
for p in crate::swatches::node_paints_mut(n) {
if matches!(p, Paint::Pattern { pattern, .. } if bad((true, pattern.as_str()), depth)) {
*p = Paint::None;
}
}
}
// An opacity mask whose art is such an instance goes with it.
let mut mask = n.extras().mask.as_ref().map(|m| vec![m.art.clone()]);
if let Some(art) = &mut mask
&& strip(art, depth + 1, bad)
{
match art.pop() {
Some(art) => {
if let Some(m) = n.extras_mut().mask.as_mut() {
m.art = art;
}
}
None => n.extras_mut().mask = None,
}
}
if let Some(ch) = n.children_mut() {
strip(ch, depth + 1, bad);
}
}
changed
}
impl Document {
/// Is anything nested deeper than [`MAX_DEPTH`], or drawn deeper than [`MAX_DRAWN_DEPTH`]
/// (a symbol or pattern that uses itself is)? With `before` (this document before an edit,
/// itself within the caps), only what the edit changed is looked at.
pub fn too_deep(&self, before: Option<&Document>) -> bool {
fn symbol(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
d.symbols.get(i).map(|s| std::slice::from_ref(&s.art))
}
fn pattern(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
d.patterns.get(i).map(|p| &p.art[..])
}
let nested = |_: &Node, _: usize| false;
if (0..self.symbols.len()).any(|i| symbol(self, i).is_some_and(|art| deeper(art, before.and_then(|b| symbol(b, i)), 1, &nested)))
|| (0..self.patterns.len()).any(|i| pattern(self, i).is_some_and(|art| deeper(art, before.and_then(|b| pattern(b, i)), 1, &nested)))
{
return true;
}
if self.symbols.is_empty() && self.patterns.is_empty() {
return deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &nested);
}
let heights = heights(self);
if heights.values().any(Option::is_none) {
return true;
}
// Symbols and patterns draw as deep as before when none changed; else every use of them
// is looked at again.
let same = |x: &[Arc<Node>], y: &[Arc<Node>]| x.len() == y.len() && x.iter().zip(y).all(|(x, y)| Arc::ptr_eq(x, y));
let before = before.filter(|b| {
b.symbols.len() == self.symbols.len()
&& b.patterns.len() == self.patterns.len()
&& b.symbols.iter().zip(&self.symbols).all(|(x, y)| x.name == y.name && Arc::ptr_eq(&x.art, &y.art))
&& b.patterns.iter().zip(&self.patterns).all(|(x, y)| x.name == y.name && same(&x.art, &y.art))
});
let over = |n: &Node, d: usize| uses(n).any(|u| undrawable(&heights, u, d));
deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &over)
}
/// [`Self::limit_depth`] → whether objects were merged, and whether uses of symbols and
/// patterns were left out.
fn limit_depths(&mut self) -> (bool, bool) {
let mut merged = limit(&mut self.layers, 1);
for s in &mut self.symbols {
// The art itself is 1 deep, so it stays the one object it is.
let mut art = vec![s.art.clone()];
if limit(&mut art, 1)
&& let Some(one) = art.pop()
{
s.art = one;
merged = true;
}
}
for p in &mut self.patterns {
merged |= limit(&mut p.art, 1);
}
if self.symbols.is_empty() && self.patterns.is_empty() {
return (merged, false);
}
// The symbols and patterns that can't be drawn lose what their art uses: each is then as
// deep as its own art, and what uses it can be drawn.
let (symbols, patterns): (Vec<usize>, Vec<usize>) = {
let heights = heights(self);
let bad = |def: Def| heights.get(&def).is_some_and(Option::is_none);
(
(0..self.symbols.len()).filter(|i| self.symbols.get(*i).is_some_and(|s| bad((false, s.name.as_str())))).collect(),
(0..self.patterns.len()).filter(|i| self.patterns.get(*i).is_some_and(|p| bad((true, p.name.as_str())))).collect(),
)
};
let mut left_out = !(symbols.is_empty() && patterns.is_empty());
let all = |_: Def, _: usize| true;
for i in symbols {
let Some(s) = self.symbols.get_mut(i) else { continue };
let mut art = vec![s.art.clone()];
strip(&mut art, 1, &all);
// (An instance alone was the whole art: an empty group stands in.)
s.art = art.pop().unwrap_or_else(|| Arc::new(Node::group(s.art.id, vec![])));
}
for i in patterns {
if let Some(p) = self.patterns.get_mut(i) {
strip(&mut p.art, 1, &all);
}
}
// Then the uses drawn too deep where they are.
let mut layers = self.layers.clone();
left_out |= {
let heights = heights(self);
strip(&mut layers, 1, &|used, depth| undrawable(&heights, used, depth))
};
self.layers = layers;
(merged, left_out)
}
/// Bring the document within the caps: merge the objects nested deeper than [`MAX_DEPTH`]
/// into the group around them, and leave out the uses of symbols and patterns that can't be
/// drawn (inside their own symbol or pattern, or deeper than [`MAX_DRAWN_DEPTH`]). What an
/// importer does to what it read. Returns whether anything changed.
pub fn limit_depth(&mut self) -> bool {
let (merged, left_out) = self.limit_depths();
merged || left_out
}
/// [`Self::limit_depth`], adding [`merged_note`] and [`symbols_note`] to an importer's
/// `warnings` for what it did.
pub fn limit_depth_noting(&mut self, warnings: &mut Vec<String>) {
let (merged, left_out) = self.limit_depths();
warnings.extend(merged.then(merged_note));
warnings.extend(left_out.then(symbols_note));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{NodeId, OpacityMask};
/// One path in `levels` groups, each holding a second path beside the next group.
fn nested(levels: usize) -> Arc<Node> {
let mut id = 0;
let mut next = || {
id += 1;
NodeId(id)
};
let path = |id| Arc::new(Node::path(id, Default::default(), Default::default()));
let mut inner = path(next());
for _ in 0..levels {
inner = Arc::new(Node::group(next(), vec![inner, path(next())]));
}
inner
}
fn doc(art: Arc<Node>) -> Document {
let mut d = Document::new(100.0, 100.0);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(art);
}
d
}
#[test]
fn a_document_at_the_cap_is_left_alone() {
// The layer, the groups and the path in them: MAX_DEPTH deep.
let mut d = doc(nested(MAX_DEPTH - 2));
let before = d.clone();
assert!(!d.too_deep(None));
assert!(!d.limit_depth());
assert_eq!(d, before);
}
#[test]
fn deeper_objects_are_merged_into_the_group_around_them() {
for levels in [MAX_DEPTH - 1, MAX_DEPTH + 5, 100_000] {
let mut d = doc(nested(levels));
assert!(d.too_deep(None), "{levels}");
assert!(d.limit_depth(), "{levels}");
assert!(!d.too_deep(None), "{levels}");
// Every path is kept, under the layer and the groups down to the cap.
assert_eq!(d.node_count(), 1 + (MAX_DEPTH - 2) + (levels + 1), "{levels}");
assert!(!d.limit_depth(), "nothing more to merge");
}
}
#[test]
fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() {
let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true });
inner.visible = false;
let mut art = Arc::new(inner);
for i in 0..MAX_DEPTH {
art = Arc::new(Node::group(NodeId(9001 + i as u64), vec![art]));
}
let mut masked = Node::group(NodeId(8000), vec![]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new((*nested(MAX_DEPTH)).clone(), true)));
let mut d = doc(art);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(Arc::new(masked));
}
assert!(d.limit_depth());
assert!(!d.too_deep(None));
let mut leaves = vec![];
d.walk(|n| {
if n.children().is_none() {
leaves.push(n.visible);
}
});
assert_eq!(leaves, [false], "the clipping path is gone, what it clipped is still hidden");
assert!(d.node(NodeId(8000)).is_some_and(|n| n.extras().mask.is_none()));
}
#[test]
fn an_edit_is_checked_by_what_it_changed() {
let before = doc(nested(MAX_DEPTH - 2));
let mut wrapped = before.clone();
if let Some(ch) = wrapped.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
let all = std::mem::take(ch);
ch.push(Arc::new(Node::group(NodeId(7000), all)));
}
assert!(wrapped.too_deep(Some(&before)), "the same art, one deeper");
let mut beside = before.clone();
if let Some(ch) = beside.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(nested(3));
}
assert!(!beside.too_deep(Some(&before)));
assert!(!before.too_deep(Some(&before)));
}
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
assert!(d.symbols.first().is_some_and(|s| s.art.children().is_some()), "still one object");
}
fn instance(id: u64, of: &str) -> Arc<Node> {
Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: of.into(), xf: Default::default() }))
}
/// `count` symbols, each `levels` groups around an instance of the next; the last one's of `last`.
fn symbols(count: usize, levels: usize, last: &str) -> Document {
let mut d = doc(instance(1, "s0"));
for i in 0..count {
let mut art = instance(2, &if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
}
d
}
fn instances(d: &Document) -> usize {
let mut n = 0;
let mut count = |x: &Node| n += usize::from(matches!(x.kind, NodeKind::SymbolInstance { .. }));
d.walk(&mut count);
d.symbols.iter().for_each(|s| s.art.walk(&mut count));
n
}
#[test]
fn symbols_in_symbols_are_drawn_only_so_deep() {
// Three objects deep each (two groups and the instance), under the instance on the layer.
let fits = MAX_DRAWN_DEPTH / 3 - 1;
let mut d = symbols(fits, 2, "none");
assert!(!d.too_deep(None) && !d.limit_depth());
let mut d = symbols(fits + 2, 2, "none");
assert!(d.too_deep(None));
let before = instances(&d);
assert!(d.limit_depth() && !d.too_deep(None));
assert!(instances(&d) < before && instances(&d) > 0, "only the instances drawn too deep go");
// A chain of any length is measured, not walked by recursion.
let mut d = symbols(100_000, 0, "none");
assert!(d.too_deep(None) && d.limit_depth() && !d.too_deep(None));
}
#[test]
fn a_symbol_holding_itself_loses_the_instance() {
for (count, last) in [(1, "s0"), (3, "s0"), (3, "s1")] {
let mut d = symbols(count, 1, last);
assert!(d.too_deep(None), "{count} {last}");
let mut warnings = vec![];
d.limit_depth_noting(&mut warnings);
assert_eq!(warnings, [symbols_note()]);
assert!(!d.too_deep(None), "{count} {last}");
assert!(d.symbols.iter().all(|s| s.art.children().is_some()), "the art is still a group");
}
// An edit that makes one hold itself is seen, whatever else stayed as it was.
let before = symbols(2, 1, "none");
let mut after = before.clone();
after.symbols[1].art = Arc::new(Node::group(NodeId(9), vec![instance(8, "s0")]));
assert!(!before.too_deep(None) && after.too_deep(Some(&before)));
}
/// A path painted with the pattern named `with`.
fn painted(id: u64, with: &str) -> Arc<Node> {
let fill = Paint::Pattern { pattern: with.into(), xf: Default::default() };
Arc::new(Node::path(NodeId(id), Default::default(), crate::Appearance::basic(fill, Paint::None, 0.0)))
}
fn pattern_fills(d: &Document) -> usize {
let mut n = 0;
d.visit_paints(&mut |p| n += usize::from(matches!(p, Paint::Pattern { .. })));
n
}
#[test]
fn a_pattern_painting_its_own_tile_loses_the_paint() {
// One that paints its own tile; two that paint each other's; one in a symbol it holds.
let itself = |d: &mut Document| d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "p")]));
let each_other = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "p")]));
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
make(&mut d);
assert!(d.too_deep(None));
let mut warnings = vec![];
d.limit_depth_noting(&mut warnings);
assert_eq!(warnings, [symbols_note()]);
assert!(!d.too_deep(None));
assert_eq!(pattern_fills(&d), 1, "the object on the layer keeps its pattern");
}
// A pattern whose tile is painted with another is fine.
let mut d = doc(painted(1, "p"));
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")]));
assert!(!d.too_deep(None) && !d.limit_depth());
}
}
+573
View File
@@ -0,0 +1,573 @@
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
use crate::{Document, NodeId, Unit};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, Point, Vec2};
/// What a dimension measures.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DimensionMode {
#[default]
Linear,
Angular,
Radius,
Diameter,
}
impl DimensionMode {
pub fn id(self) -> &'static str {
match self {
Self::Linear => "linear",
Self::Angular => "angular",
Self::Radius => "radius",
Self::Diameter => "diameter",
}
}
}
/// The unit a length is labelled in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LengthUnit {
#[default]
Pt,
Pc,
Px,
In,
Mm,
Cm,
M,
Ft,
Yd,
}
impl LengthUnit {
pub const ALL: [LengthUnit; 9] = [
LengthUnit::In,
LengthUnit::Mm,
LengthUnit::Pt,
LengthUnit::Pc,
LengthUnit::Px,
LengthUnit::Cm,
LengthUnit::M,
LengthUnit::Ft,
LengthUnit::Yd,
];
pub fn unit(self) -> Unit {
match self {
Self::Pt => Unit::Points,
Self::Pc => Unit::Picas,
Self::Px => Unit::Pixels,
Self::In => Unit::Inches,
Self::Mm => Unit::Millimeters,
Self::Cm => Unit::Centimeters,
Self::M => Unit::Meters,
Self::Ft => Unit::Feet,
Self::Yd => Unit::Yards,
}
}
/// The suffix written after a length.
pub fn suffix(self) -> &'static str {
match self {
Self::Pt => "pt",
Self::Pc => "pc",
Self::Px => "px",
Self::In => "in",
Self::Mm => "mm",
Self::Cm => "cm",
Self::M => "m",
Self::Ft => "ft",
Self::Yd => "yd",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LineType {
#[default]
Solid,
Dashed,
Dotted,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ArrowStyle {
#[default]
Filled,
Open,
Tick,
None,
}
/// Where the label sits across the dimension line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextPosition {
/// On the side of the dimension line away from the measured object.
#[default]
Away,
/// Between the dimension line and the measured object.
Between,
}
/// Where the label sits along the dimension line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextAlignment {
Left,
#[default]
Center,
Right,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimensionOptions {
pub units: LengthUnit,
pub precision: usize,
/// Drawing : real size. 1:10 multiplies the drawn length by ten.
pub scale: String,
pub hide_units: bool,
pub line_weight: f64,
pub line_type: LineType,
pub line_color: [u8; 3],
pub arrow_style: ArrowStyle,
pub arrow_scale: f64,
pub extension_weight: f64,
pub extension_type: LineType,
pub extension_color: [u8; 3],
pub extension_offset: f64,
pub extension_length: f64,
pub hide_extensions: bool,
pub font_family: String,
pub font_style: String,
pub font_size: f64,
pub text_color: [u8; 3],
pub text_position: TextPosition,
pub text_alignment: TextAlignment,
pub text_offset: f64,
}
impl Default for DimensionOptions {
fn default() -> Self {
// The document's own type defaults: a font every build has, black, in points.
let type_style = crate::CharStyle::default();
Self {
units: LengthUnit::Pt,
precision: 2,
scale: "1:1".into(),
hide_units: false,
line_weight: 0.5,
line_type: LineType::Solid,
line_color: [0, 0, 0],
arrow_style: ArrowStyle::Filled,
arrow_scale: 100.0,
extension_weight: 0.5,
extension_type: LineType::Solid,
extension_color: [0, 0, 0],
extension_offset: 4.0,
extension_length: 6.0,
hide_extensions: false,
font_family: type_style.font_family,
font_style: type_style.font_style,
font_size: type_style.size,
text_color: [0, 0, 0],
text_position: TextPosition::Away,
text_alignment: TextAlignment::Center,
text_offset: 4.0,
}
}
}
impl DimensionOptions {
/// Options given to a command or a tool: unknown keys are refused there, while files load
/// tolerantly so a newer version's options don't stop a document opening.
pub fn from_params(v: serde_json::Value) -> Result<Self, String> {
if let (Some(given), Ok(serde_json::Value::Object(known))) = (v.as_object(), serde_json::to_value(Self::default()))
&& let Some(k) = given.keys().find(|k| !known.contains_key(*k))
{
return Err(format!("Unknown dimension option {k}"));
}
let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
o.validate()?;
Ok(o)
}
pub fn factor(&self) -> Result<f64, String> {
let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?;
let parse = |s: &str| s.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9);
let a = parse(a).ok_or("Invalid drawing scale")?;
let b = parse(b).ok_or("Invalid real scale")?;
let f = b / a;
if !(1e-6..=1e6).contains(&f) {
return Err("Scale ratio must be between 1:1000000 and 1000000:1".into());
}
Ok(f)
}
pub fn validate(&self) -> Result<(), String> {
self.factor()?;
if self.precision > 6 {
return Err("Choose a precision from 0 to 6".into());
}
for (v, min, max) in [
(self.line_weight, 0.1, 100.0),
(self.extension_weight, 0.1, 100.0),
(self.arrow_scale, 1.0, 1000.0),
(self.font_size, 1.0, 1000.0),
(self.extension_offset, 0.0, 10000.0),
(self.extension_length, 0.0, 10000.0),
(self.text_offset, 0.0, 10000.0),
] {
if !v.is_finite() || v < min || v > max {
return Err("Dimension size is outside its supported range".into());
}
}
if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
return Err("Invalid dimension font name".into());
}
Ok(())
}
/// `value` in points for a length, in degrees for an angle.
pub fn label(&self, value: f64, angle: bool) -> Result<String, String> {
self.validate()?;
let value = if angle { value } else { value * self.factor()? / self.units.unit().points() };
if !value.is_finite() {
return Err("Non-finite dimension".into());
}
let suffix = if self.hide_units {
""
} else if angle {
"°"
} else {
self.units.suffix()
};
let sep = if suffix.is_empty() || angle { "" } else { " " };
Ok(format!("{:.*}{sep}{suffix}", self.precision, value))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SegmentRef {
pub node: NodeId,
pub subpath: usize,
pub segment: usize,
}
impl SegmentRef {
/// The segment's two end points.
pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point)> {
let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
if self.segment >= sp.segment_count() {
return None;
}
let seg = sp.segment(self.segment);
Some((seg.p0, seg.p3))
}
/// Whether the segment is a straight line.
pub fn is_straight(&self, doc: &Document) -> bool {
doc.node(self.node)
.and_then(|n| n.path_data())
.and_then(|p| p.subpaths.get(self.subpath))
.is_some_and(|sp| self.segment < sp.segment_count() && sp.segment_is_line(self.segment))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum DimensionSource {
Segment {
edge: SegmentRef,
},
Angle {
first: SegmentRef,
second: SegmentRef,
},
Circle {
node: NodeId,
},
/// One segment that is a perfect arc.
Arc {
edge: SegmentRef,
},
Points {
a: Option<PointRef>,
b: Option<PointRef>,
vertex: Option<PointRef>,
},
}
impl DimensionSource {
pub fn nodes(&self) -> Vec<NodeId> {
match self {
Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
Self::Angle { first, second } => vec![first.node, second.node],
Self::Circle { node } => vec![*node],
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
}
}
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
/// edits keep their topology and remain linked.
pub fn topology_changed(&self, before: &Document, after: &Document) -> bool {
if matches!(self, Self::Circle { .. }) {
return false;
}
self.nodes().iter().any(|id| {
let counts = |doc: &Document| {
doc.node(*id).and_then(|n| n.path_data()).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect::<Vec<_>>())
};
let old = counts(before);
old.is_some() && old != counts(after)
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointRef {
pub node: NodeId,
pub subpath: usize,
pub anchor: usize,
}
impl PointRef {
pub fn position(&self, doc: &Document) -> Option<Point> {
Some(doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?.anchors.get(self.anchor)?.p)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dimension {
pub mode: DimensionMode,
pub a: Point,
pub b: Point,
/// Angular vertex. For radius/diameter a is the centre and b lies on the circle.
pub vertex: Point,
pub offset: f64,
pub length: f64,
pub options: DimensionOptions,
pub source: Option<DimensionSource>,
/// How far a linked dimension was moved on its own since the last refresh: the refresh turns
/// it into a new placement unless the measured art moved too.
#[serde(skip)]
pub moved: Option<Vec2>,
}
impl Dimension {
/// A point on the drawn dimension line (linear), arc (angular) or radius line.
pub fn handle(&self) -> Point {
match self.mode {
DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
DimensionMode::Radius | DimensionMode::Diameter => self.b,
}
}
/// The left normal of a → b.
pub fn normal(&self) -> Vec2 {
let u = self.b - self.a;
let len = u.hypot();
if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
}
fn bisector(&self) -> Vec2 {
let start = (self.a - self.vertex).atan2();
Vec2::from_angle(start + self.angle() * 0.5)
}
/// Places the dimension through `p`: the line's distance from the art (linear), the arc's
/// radius (angular) or the label's direction (radial).
pub fn place(&mut self, p: Point) {
match self.mode {
DimensionMode::Linear => self.offset = (p - self.a).dot(self.normal()),
DimensionMode::Angular => self.offset = p.distance(self.vertex).max(12.0),
DimensionMode::Radius | DimensionMode::Diameter => {
if p.distance(self.a) > 1e-6 {
let r = self.a.distance(self.b);
let u = p - self.a;
self.b = self.a + u / u.hypot() * r;
}
}
}
}
pub fn transform(&mut self, xf: Affine) {
if self.source.is_some() {
// The art it measures decides where its ends are: record the move, and the refresh
// after this edit turns it into a new placement unless the art moved with it.
let at = self.handle();
self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
return;
}
self.a = xf * self.a;
self.b = xf * self.b;
self.vertex = xf * self.vertex;
if self.mode == DimensionMode::Linear {
self.length = self.a.distance(self.b);
}
}
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
match &self.source {
Some(DimensionSource::Points { a, b, vertex }) => {
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
if let Some(r) = reference {
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
}
}
self.length = self.a.distance(self.b);
}
Some(DimensionSource::Segment { edge }) => {
let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
self.a = a;
self.b = b;
self.length = a.distance(b);
}
Some(DimensionSource::Angle { first, second }) => {
for edge in [first, second] {
if !edge.is_straight(doc) {
return Err("Angles require two straight segments".into());
}
}
let (a, b) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
let (c, d) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
let u = b - a;
let v = d - c;
let den = u.cross(v);
if den.abs() < 1e-9 {
return Err("Choose two non-parallel straight segments".into());
}
let p = a + u * ((c - a).cross(v) / den);
self.vertex = p;
self.a = if a.distance(p) > b.distance(p) { a } else { b };
self.b = if c.distance(p) > d.distance(p) { c } else { d };
}
Some(DimensionSource::Circle { node }) => {
let (center, radius) = circle(doc, *node).ok_or("Choose a circle or a perfect arc")?;
self.set_radial(center, radius);
}
Some(DimensionSource::Arc { edge }) => {
let (center, radius) = arc(doc, edge).ok_or("Choose a circle or a perfect arc")?;
self.set_radial(center, radius);
}
None => {}
}
self.validate()
}
fn set_radial(&mut self, center: Point, radius: f64) {
let direction = self.b - self.a;
let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { Vec2::new(1.0, 0.0) };
self.a = center;
self.b = center + dir * radius;
self.length = radius;
}
pub fn validate(&self) -> Result<(), String> {
self.options.validate()?;
for p in [self.a, self.b, self.vertex] {
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8 {
return Err("Dimension point is outside the canvas".into());
}
}
if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 {
return Err("Invalid dimension distance".into());
}
if self.a.distance(self.b) < 1e-6 {
return Err("Choose two different points".into());
}
if self.mode == DimensionMode::Angular && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
return Err("Choose two nonzero angle arms".into());
}
Ok(())
}
pub fn angle(&self) -> f64 {
let u = self.a - self.vertex;
let v = self.b - self.vertex;
u.cross(v).atan2(u.dot(v))
}
pub fn label(&self) -> Result<String, String> {
let value = match self.mode {
DimensionMode::Angular => self.angle().abs().to_degrees(),
DimensionMode::Radius => self.a.distance(self.b),
DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
DimensionMode::Linear => self.a.distance(self.b),
};
self.options.label(value, self.mode == DimensionMode::Angular)
}
}
/// Accept circular paths (including imported circles), reject ellipses and arbitrary closed shapes.
pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> {
use vectorcraft_geom::kurbo::ParamCurve;
let path = doc.node(id)?.path_data()?;
let sp = path.subpaths.first()?;
if path.subpaths.len() != 1 || !sp.closed || !(2..=64).contains(&sp.segment_count()) {
return None;
}
let bounds = path.bounds()?;
let center = bounds.center();
let radius = bounds.width() * 0.5;
if radius < 1e-6 || (bounds.height() * 0.5 - radius).abs() > radius * 0.002 {
return None;
}
for i in 0..sp.segment_count() {
for t in [0.0, 0.25, 0.5, 0.75] {
if (sp.segment(i).eval(t).distance(center) - radius).abs() > radius * 0.002 {
return None;
}
}
}
Some((center, radius))
}
/// A curved segment that follows one circle (a perfect arc): its centre and radius.
pub fn arc(doc: &Document, edge: &SegmentRef) -> Option<(Point, f64)> {
use vectorcraft_geom::kurbo::ParamCurve;
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
if edge.segment >= sp.segment_count() || sp.segment_is_line(edge.segment) {
return None;
}
let seg = sp.segment(edge.segment);
let (p, q, r) = (seg.p0, seg.eval(0.5), seg.p3);
// The circle through the ends and the middle of the segment.
let d = 2.0 * (p.x * (q.y - r.y) + q.x * (r.y - p.y) + r.x * (p.y - q.y));
if d.abs() < 1e-9 {
return None;
}
let sq = |a: Point| a.x * a.x + a.y * a.y;
let center = Point::new(
(sq(p) * (q.y - r.y) + sq(q) * (r.y - p.y) + sq(r) * (p.y - q.y)) / d,
(sq(p) * (r.x - q.x) + sq(q) * (p.x - r.x) + sq(r) * (q.x - p.x)) / d,
);
let radius = center.distance(p);
if !radius.is_finite() || !(1e-6..=1e8).contains(&radius) {
return None;
}
for t in [0.125, 0.25, 0.375, 0.625, 0.75, 0.875] {
if (seg.eval(t).distance(center) - radius).abs() > radius * 0.002 {
return None;
}
}
Some((center, radius))
}
/// The two straight segments that meet at a corner anchor, if both are straight.
pub fn corner(doc: &Document, node: NodeId, subpath: usize, anchor: usize) -> Option<(SegmentRef, SegmentRef)> {
let sp = doc.node(node)?.path_data()?.subpaths.get(subpath)?;
let count = sp.segment_count();
let n = sp.anchors.len();
if count < 2 || anchor >= n {
return None;
}
// Segment i runs from anchor i to anchor i + 1 (wrapping on closed paths).
let before = if anchor > 0 {
anchor - 1
} else if sp.closed {
count - 1
} else {
return None;
};
let after = anchor;
if after >= count {
return None;
}
let first = SegmentRef { node, subpath, segment: before };
let second = SegmentRef { node, subpath, segment: after };
(first.is_straight(doc) && second.is_straight(doc)).then_some((first, second))
}
+26
View File
@@ -422,6 +422,32 @@ impl GraphSpec {
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();
}
/// CSV of this graph's data (header row with series names, then category + values), as Graph
/// Data and `graph.setData` use. Always writes a header so a blank first category isn't
/// misread as one.
pub fn to_csv(&self) -> String {
let cells = self.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !self.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
let c = self.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| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
}
out
}
}
impl Default for GraphSpec {
+312 -43
View File
@@ -1,9 +1,11 @@
//! Hit testing over the document tree.
use std::collections::HashSet;
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{Point, Rect};
use crate::{Document, Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitKind {
@@ -83,6 +85,79 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
// while a group (including a clipping group) is selected as one object. Repeatedly
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
// Isolation and Edit Contents change where selection groups begin; keep the original
// traversal for those cases rather than change their Select Behind semantics.
if opt.scope.is_some() {
return objects_at_repeated(doc, p, opt, scope);
}
let mut tops = Vec::new();
let mut seen = HashSet::new();
let symbols = doc.symbol_boxes();
for layer in doc.layers.iter().rev() {
// Malformed files may contain non-layer roots; let the general traversal handle them.
if !layer.is_layer() {
return objects_at_repeated(doc, p, opt, scope);
}
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
if !objects_at_layer(layer, p, opt, &symbols, &mut tops, &mut seen) {
// An envelope in Edit Contents may expose objects *within* a top-level group.
// The repeated traversal handles that mixed hierarchy precisely.
return objects_at_repeated(doc, p, opt, scope);
}
}
tops
}
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
/// inspected once (only until its first hit). `false` asks the caller to use the general
/// algorithm for an envelope whose contents are currently being edited. Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
let Some(children) = layer.children() else { return true };
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return true;
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
for (i, c) in children.iter().enumerate().rev() {
if !may_hit(layer, i, c, p, opt, symbols, true) || seen.contains(&c.id) {
continue;
}
if c.is_layer() {
if !objects_at_layer(c, p, opt, symbols, tops, seen) {
return false;
}
continue;
}
// Editing an envelope selects its contents rather than the containing object. Even
// when nested in a group, the old traversal may select both a member and the group.
if c.is_container() {
let mut editing = false;
c.walk(&mut |n| editing |= edits_contents(n));
if editing {
return false;
}
}
let skip = |id| seen.contains(&id);
let mut chain = Vec::new();
let hit = match &c.kind {
NodeKind::Group { .. } | NodeKind::Intertwine { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
_ => hit_leaf(c, p, opt, symbols).is_some(),
};
if hit && seen.insert(c.id) {
tops.push(c.id);
}
}
true
}
/// The general selection order also handles isolation and nested Edit Contents.
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
@@ -94,13 +169,15 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
tops
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
let symbols = doc.symbol_boxes();
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if editable_only
@@ -109,12 +186,12 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
&& let Some(mut chain) = doc.ancestry(scope)
{
if isolated.is_container() {
return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish);
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
}
// A single object isolated (a path, an image…): only it hits, among its siblings.
chain.pop();
let parent = chain.last().and_then(|id| doc.node(*id))?;
return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
return hit_children(parent, p, opt, &symbols, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
@@ -127,7 +204,7 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain, editable_only) {
if let Some(h) = hit_children(layer, p, opt, &symbols, skip, &mut chain, editable_only) {
return Some(finish(h));
}
chain.pop();
@@ -150,10 +227,29 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) {
return false;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
fn hit_children(
parent: &Node,
p: Point,
opt: HitOptions,
symbols: &SymbolBoxes,
skip: &dyn Fn(NodeId) -> bool,
chain: &mut Vec<NodeId>,
editable_only: bool,
@@ -166,37 +262,25 @@ fn hit_children(
{
return None;
}
for (i, c) in children.iter().enumerate().rev() {
if parent.extras().shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !(edits_contents(c)
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
for i in hit_order(parent, children, p) {
let Some(c) = children.get(i) else { continue };
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
continue;
}
chain.push(c.id);
let hit = match &c.kind {
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain, editable_only),
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. } => {
hit_children(c, p, opt, symbols, skip, chain, editable_only)
}
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain, editable_only),
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
};
if hit.is_some() {
return hit;
@@ -206,6 +290,23 @@ fn hit_children(
None
}
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
/// come first (from the top among them too).
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
} else {
vec![]
};
let rest = (0..children.len()).rev().filter({
let over = over.clone();
move |i| !over.contains(i)
});
over.into_iter().chain(rest)
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
/// outline within the tolerance. Outline mode hits the outline only.
@@ -216,7 +317,7 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -246,8 +347,8 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
| NodeKind::PlacedDocument(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
if stroke_contains(&bp, 0.0, opt.tol, p) {
@@ -282,14 +383,15 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
}
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt)),
_ => hit_leaf(n, p, opt),
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
@@ -320,38 +422,44 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
out
}
fn touches(n: &Node, r: Rect) -> bool {
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
children.iter().any(|c| c.visible && touches(c, r))
children.iter().any(|c| c.visible && touches(c, s, r))
}
_ => n.geometric_bounds().is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.origin())),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
}),
}
}
fn collect_leaves(n: &Node, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
if c.visible && !c.locked {
collect_leaves(c, r, out);
collect_leaves(c, s, r, out);
}
}
}
_ => {
if touches(n, r) {
if touches(n, s, r) {
out.push(n.id);
}
}
}
}
let symbols = doc.symbol_boxes();
for t in tops {
if !t.visible || t.locked {
continue;
}
if leaves {
collect_leaves(t, r, &mut out);
} else if touches(t, r) {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
out.push(t.id);
}
}
@@ -619,6 +727,167 @@ world",
assert!(at(90.0, None).is_empty());
}
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
/// the per-object walk.
#[test]
fn objects_at_large_overlapping_stack() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = Vec::new();
for _ in 0..256 {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
ids.push(id);
}
ids.reverse();
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
}
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
/// invisibility, and path/outline-only settings.
#[test]
fn objects_at_one_pass_matches_repeated_hits() {
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let add = |d: &mut Document, parent, bounds| {
let id = d.alloc_id();
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
id
};
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
let sub = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
let group = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
let clip = d.alloc_id();
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
clipping.set_clips(true);
d.insert(Some(layer), usize::MAX, clipping).unwrap();
for (point, opt) in [
(Point::new(25.0, 25.0), HitOptions::default()),
(Point::new(3.0, 25.0), HitOptions::default()),
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
(Point::new(100.0, 100.0), HitOptions::default()),
] {
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
}
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
*preview = false;
}
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
d.node_mut(group).unwrap().locked = true;
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
}
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
/// in the same order as the hit test repeated over what is left.
#[test]
fn objects_at_matches_repeated_hits_on_random_documents() {
let mut seed = 0x9e37_79b9_u32;
let mut next = move |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
for _ in 0..2 + next(4) {
let id = d.alloc_id();
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
4 | 5 => Node::group(id, Vec::new()),
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
7 => {
let mut g = Node::group(id, Vec::new());
g.set_clips(true);
g
}
_ => Node::path(id, shapes::rectangle(rect), appearance),
};
n.visible = next(8) != 0;
n.locked = next(8) == 0;
if let NodeKind::Layer { preview, .. } = &mut n.kind {
*preview = next(3) != 0;
}
let container = n.is_container();
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
d.insert(Some(parent), usize::MAX, n).unwrap();
if container {
fill(d, id, depth + 1, next);
}
}
}
for round in 0..200 {
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
}
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
/// one instance and 100 pt lower by an instance in a group.
#[test]
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance =
|id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
let opt = HitOptions::default();
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
let p = Point::new(x, y);
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
}
}
/// Edit Contents can expose descendants of a top-level group: preserve the original
/// selection order by falling back to the general hit traversal.
#[test]
fn objects_at_edit_contents_uses_general_order() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
let (a, b) = (d.alloc_id(), d.alloc_id());
let e = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: true,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(layer), usize::MAX, e).unwrap();
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
+98
View File
@@ -0,0 +1,98 @@
//! Object › Intertwine: objects that pass over and under each other.
//!
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
//! stacking order. A member can be drawn over the other members in part of the group: that area
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
//! over the others in one place, the upper one in the stacking order ends on top.
//!
//! The members stay editable, and Release gives them back as they were (the areas let go).
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
use std::sync::Arc;
use vectorcraft_geom::{FillRule, PathData};
use crate::{Node, NodeId, NodeKind};
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
pub fn over_area(n: &Node) -> Option<&PathData> {
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
/// member with an area drawn over the others, as a clip group of that area around a copy of it
/// (ids 0: generated art).
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
for c in children {
let Some(area) = over_area(c) else { continue };
if !c.visible {
continue;
}
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = crate::Appearance::default().into();
let mut copy = plain(c);
zero_ids(&mut copy);
out.push(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }));
}
out
}
/// `n` without its Intertwine area (the expansion is plain art).
fn plain(n: &Node) -> Node {
let mut n = n.clone();
if n.extras().intertwine.is_some() {
n.extras_mut().intertwine = None;
}
n
}
fn zero_ids(n: &mut Node) {
n.id = NodeId(0);
for c in n.children_mut().into_iter().flatten() {
zero_ids(Arc::make_mut(c));
}
}
#[cfg(test)]
mod tests {
use vectorcraft_geom::{Rect, shapes};
use super::*;
fn square(id: u64, x: f64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), crate::Appearance::default()))
}
#[test]
fn members_without_areas_expand_to_themselves() {
let kids = vec![square(1, 0.0), square(2, 5.0)];
let out = expand(&kids);
assert_eq!(out.len(), 2);
assert_eq!(out.iter().map(|n| n.id).collect::<Vec<_>>(), [NodeId(1), NodeId(2)]);
}
#[test]
fn a_member_with_an_area_is_drawn_again_clipped_to_it_on_top() {
let mut a = (*square(1, 0.0)).clone();
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
let kids = vec![Arc::new(a), square(2, 5.0)];
let out = expand(&kids);
assert_eq!(out.len(), 3, "both members, then the raised copy");
assert!(out.iter().all(|n| n.extras().intertwine.is_none()), "the expansion is plain art");
let NodeKind::Group { children, clip: true } = &out[2].kind else { panic!("a clip group: {:?}", out[2].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert_eq!(children[1].path_data(), kids[0].path_data());
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
}
#[test]
fn a_hidden_member_is_not_drawn_over_the_others() {
let mut a = (*square(1, 0.0)).clone();
a.visible = false;
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0))));
assert_eq!(expand(&[Arc::new(a), square(2, 5.0)]).len(), 2);
}
}
+219 -30
View File
@@ -11,14 +11,19 @@ pub mod blend;
pub mod clipnest;
pub mod cmyk;
pub mod corners;
pub mod depth;
pub mod dimension;
pub mod graph;
pub mod hit;
mod id_index;
pub mod inks;
mod inline;
pub mod intertwine;
pub mod shared;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
pub mod marks;
@@ -30,6 +35,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub mod placed_document;
pub mod profiles;
pub mod puppet;
@@ -43,9 +49,12 @@ pub mod shaper;
pub mod slices;
pub mod style_libs;
pub mod swatches;
mod symbols;
pub use symbols::SymbolBoxes;
pub mod text;
pub mod trace;
pub mod variables;
pub mod xml_id;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -76,6 +85,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use depth::MAX_DEPTH;
pub use graph::{
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
ValueAxisSide,
@@ -113,6 +123,9 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
/// Coordinates beyond this many points exceed the supported document range.
pub const MAX_COORD: f64 = 4.0e6;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
#[error("no such object {0}")]
@@ -226,7 +239,10 @@ impl Unit {
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works.
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
/// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away
/// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A
/// percentage on its own (`50%`) reads as nothing.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self).filter(|v| v.is_finite())
}
@@ -263,20 +279,41 @@ impl Unit {
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
let s = s.trim();
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
for op in ['+', '-', '*', '/'] {
// `+` and `-` share one precedence level, as do `*` and `/`, so each level splits at the last
// of its operators (left to right).
for ops in [&['+', '-'][..], &['*', '/'][..]] {
// Past the first character, so a leading sign isn't an operator.
let skip = s.chars().next().map_or(0, char::len_utf8);
if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) {
// A `+` or `-` right after another operator is the sign of its operand (`10*-2`, `5--3`).
let is_sign = |i: usize| {
s.get(i..).and_then(|r| r.chars().next()).is_some_and(|c| matches!(c, '+' | '-'))
&& s.get(..i).and_then(|l| l.trim_end().chars().last()).is_some_and(|c| "+-*/".contains(c))
};
let found = s.char_indices().rev().find(|&(i, c)| i >= skip && ops.contains(&c) && !is_sign(i));
if let Some(i) = found.map(|(i, _)| i) {
let (l, r) = (&s[..i], &s[i + 1..]);
if l.trim().is_empty() {
continue;
}
let Some(op) = s[i..].chars().next() else { continue };
return match op {
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
'+' | '-' => {
let a = parse_measure(l, default)?;
// A percentage added or taken away is a share of everything before it:
// `50pt+25%` is 62.5 pt, `100pt+20%-10pt` is 110 pt.
let b = match percent(r) {
Some(k) => a * k,
None => parse_measure(r, default)?,
};
Some(if op == '+' { a + b } else { a - b })
}
_ => {
// A factor or divisor is a plain number; a unit after it is the expression's.
let (k, unit) = number_unit(r)?;
// A factor or divisor is a plain number or a percentage (`3cm*50%` is 1.5 cm);
// a unit after it is the expression's.
let (k, unit) = match percent(r) {
Some(k) => (k, None),
None => number_unit(r)?,
};
let a = parse_measure(l, unit.unwrap_or(default))?;
if op == '*' {
Some(a * k)
@@ -296,7 +333,10 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
&& !s.ends_with("pt")
&& !s.ends_with("px")
{
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
// The sign belongs to the whole value: `-2p6` is -(2 picas + 6 points).
let sign = if p.trim_start().starts_with('-') { -1.0 } else { 1.0 };
let picas = p.trim().parse::<f64>().ok()?.abs();
return Some(sign * (picas * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0)));
}
let (v, unit) = number_unit(s)?;
Some(unit.unwrap_or(default).to_pt(v))
@@ -323,6 +363,14 @@ fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
Some((v, unit))
}
/// A number followed by `%` (`50%`, `12.5 %`) → its fraction (`0.5`, `0.125`).
fn percent(s: &str) -> Option<f64> {
match number_unit(s.trim().strip_suffix('%')?)? {
(p, None) => Some(p / 100.0),
_ => None,
}
}
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
pub fn parse_number(s: &str) -> Option<f64> {
@@ -680,6 +728,10 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
/// is set, so documents without any pay nothing for them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dimensions: bool,
/// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved.
#[serde(skip)]
index: id_index::IdIndex,
@@ -700,6 +752,13 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
/// Rulers, coordinate fields and guide snapping use the same supported range.
pub fn ruler_origin_or_zero(&self) -> Point {
let origin = self.ruler_origin;
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
}
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self {
Self::new_with_mode(width, height, ColorMode::Rgb)
@@ -760,6 +819,7 @@ impl Document {
print_setup: None,
assets: vec![],
puppet: None,
dimensions: false,
index: Default::default(),
};
let id = d.alloc_id();
@@ -770,7 +830,9 @@ impl Document {
/// Allocate a fresh node id.
pub fn alloc_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
// Never reached from a counter [`Self::fix_next_id`] has seen (it stays under
// [`MAX_NEXT_ID`]); saturating so that no counter can overflow.
self.next_id = self.next_id.saturating_add(1);
id
}
pub fn peek_next_id(&self) -> u64 {
@@ -778,14 +840,22 @@ impl Document {
}
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
pub fn fix_next_id(&mut self) {
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
// counter up there: new ids start under them and never reach them, where counting on from
// the largest would run out and hand out ids the document already uses.
let mut max = 0;
let mut see = |id: u64| {
if id < MAX_NEXT_ID {
max = max.max(id);
}
};
for l in &self.layers {
l.walk(&mut |n| max = max.max(n.id.0));
l.walk(&mut |n| see(n.id.0));
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
self.next_id = self.next_id.max(max.saturating_add(1));
self.slices.iter().for_each(|s| see(s.id.0));
self.assets.iter().for_each(|a| see(a.id));
let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0);
self.next_id = counter.max(max.saturating_add(1));
}
/// Find a node anywhere in the tree.
@@ -1207,33 +1277,36 @@ impl Document {
_ => LAYER_COLORS[0].1,
}
}
/// Union of the geometric bounds of `ids`.
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
/// instances measured by their art ([`Node::geometric_bounds_with`]).
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
ids.iter()
.filter_map(|id| self.node(*id))
.fold(None, |acc, n| vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds() } else { n.geometric_bounds() }))
let symbols = self.symbol_boxes();
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
})
}
/// Bounds of all art.
/// Visual bounds of all art, with symbol instances measured by their art.
pub fn art_bounds(&self) -> Option<Rect> {
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds()))
let symbols = self.symbol_boxes();
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
}
/// Artboard index containing point `p` (topmost = last).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it. Locked and hidden objects and layers stay put unless
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
/// Artboard).
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
/// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display ›
/// Move Locked and Hidden Artwork with Artboard).
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
// Art drawn flush with the artboard can end up a rounding error outside it (a background
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
let rect = rect.inflate(1e-6, 1e-6);
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() {
collect(c, rect, all, out);
} else if let Some(b) = c.geometric_bounds()
collect(c, s, rect, all, out);
} else if let Some(b) = c.geometric_bounds_with(s)
// Inside it, edges included: art flush with the right or bottom edge is on it too
// (`Rect::contains` leaves those out, #890).
&& b.x0 >= rect.x0
@@ -1245,9 +1318,10 @@ impl Document {
}
}
}
let symbols = self.symbol_boxes();
let mut art = vec![];
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
collect(l, rect, locked_and_hidden, &mut art);
collect(l, &symbols, rect, locked_and_hidden, &mut art);
}
art
}
@@ -1367,7 +1441,7 @@ impl Document {
self.guides.extend(copies);
}
pub fn next_artboard_id(&self) -> u32 {
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
free_id(self.artboards.iter().map(|a| a.id))
}
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
self.patterns.iter().find(|p| p.name == name)
@@ -1379,6 +1453,13 @@ impl Document {
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
// A copied annotation must never bind its source ids to unrelated art in a different
// document. Keep the measured geometry; new measurements can be linked after pasting.
if let Some(e) = n.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.source = None;
}
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
@@ -1451,7 +1532,7 @@ impl Document {
}
/// An id no graphic style has.
pub fn next_graphic_style_id(&self) -> u32 {
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
free_id(self.graphic_styles.iter().map(|g| g.id))
}
/// The id of graphic style `index`, assigning one if it has none yet.
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
@@ -1465,6 +1546,38 @@ impl Document {
pub fn new_graphic_style_name(&self) -> String {
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
}
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
pub fn unique_symbol_name(&self, base: &str) -> String {
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
/// Symbol Options › Registration for `name` (default: centre).
pub fn symbol_registration(&self, name: &str) -> SymbolRegistration {
self.unknown
.get(SYMBOL_REGISTRATION)
.and_then(|m| m.get(name))
.and_then(|v| v.as_u64())
.and_then(SymbolRegistration::from_u64)
.unwrap_or_default()
}
/// Set or clear (when centre) the registration point stored for `name`.
pub fn set_symbol_registration(&mut self, name: &str, reg: SymbolRegistration) {
let m = self.unknown.entry(SYMBOL_REGISTRATION.to_string()).or_insert_with(|| serde_json::json!({}));
let Some(o) = m.as_object_mut() else { return };
if reg.is_center() {
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_REGISTRATION);
}
} else {
o.insert(name.into(), serde_json::json!(reg.0));
}
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
@@ -1482,11 +1595,29 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
.collect()
}
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
const MAX_NEXT_ID: u64 = 1 << 62;
/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there
/// is (a damaged file), the smallest one free.
fn free_id(used: impl Iterator<Item = u32>) -> u32 {
let mut used: Vec<u32> = used.collect();
if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) {
return next;
}
used.sort_unstable();
used.dedup();
// Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them.
let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count();
u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1))
}
impl Document {
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
pub fn reserve_ids(&mut self, next: u64) {
self.next_id = self.next_id.max(next);
self.next_id = self.next_id.max(next.min(MAX_NEXT_ID));
}
}
@@ -1554,6 +1685,17 @@ mod tests {
}
}
#[test]
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
@@ -1600,6 +1742,45 @@ mod tests {
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
}
/// #1718: a file whose ids or counters are at their type's largest value still hands out ids
/// nothing in it uses (and never 0), instead of overflowing.
#[test]
fn exhausted_ids_in_a_file_dont_run_out() {
let (d, a, b) = doc_with_rects();
let mut v = serde_json::to_value(&d).unwrap();
v["next_id"] = serde_json::json!(u64::MAX);
// Object `a` gets the largest id there is.
fn renumber(v: &mut serde_json::Value, from: u64) {
match v {
serde_json::Value::Object(o) => {
if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) {
o.insert("id".into(), serde_json::json!(u64::MAX));
}
o.values_mut().for_each(|c| renumber(c, from));
}
serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)),
_ => {}
}
}
renumber(&mut v["layers"], a.0);
let mut d: Document = serde_json::from_value(v).unwrap();
assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept");
d.fix_next_id();
let fresh: Vec<NodeId> = (0..3).map(|_| d.alloc_id()).collect();
assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}");
assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]);
// A counter left at the end by other means stays there rather than wrapping to 0.
d.next_id = u64::MAX;
assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX));
d.reserve_ids(u64::MAX);
// Artboards and graphic styles: one past the largest, else the smallest id free.
assert_eq!(free_id([1, 2, 7].into_iter()), 8);
assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3);
assert_eq!(free_id([u32::MAX].into_iter()), 1);
d.artboards[0].id = u32::MAX;
assert_eq!(d.next_artboard_id(), 1);
}
#[test]
fn serde_roundtrip() {
let (d, _, _) = doc_with_rects();
@@ -1672,6 +1853,14 @@ mod tests {
assert_eq!(Unit::Points.format(12.5), "12.5 pt");
assert_eq!(Unit::Inches.format(144.0), "2 in");
}
#[test]
fn negative_picas_and_points_sign_applies_to_whole_value() {
assert_eq!(Unit::Points.parse("-2p6"), Some(-30.0));
assert_eq!(Unit::Points.parse("-0p6"), Some(-6.0));
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
}
}
#[cfg(test)]
+65 -13
View File
@@ -318,8 +318,10 @@ impl WarpStyle {
}
}
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
/// proportions).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
use WarpStyle::*;
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
if b.abs() < 1e-6 {
(x, y)
} else {
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
// the edges are concentric arcs, and one that would pass the centre stops there
// instead of folding over (#1578).
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
let sweep = b.abs() * PI;
let r0 = 2.0 / sweep;
let yy = if b > 0.0 { y } else { -y };
let yy = if b > 0.0 { y } else { -y } * k;
let a = x / r0;
let r = r0 - yy;
let r = (r0 - yy).max(0.0);
let (s, c) = a.sin_cos();
let (nx, ny) = (r * s, r0 - r * c);
let (nx, ny) = (r * s, (r0 - r * c) / k);
(nx, if b > 0.0 { ny } else { -ny })
}
}
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
Box::new(move |u: f64, v: f64| {
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
let (x2, y2) = if horizontal {
warp_point(st, b, dh, dv, x, y)
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
} else {
let (a, b2) = warp_point(st, b, dh, dv, y, x);
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
(b2, a)
};
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
@@ -1431,7 +1437,7 @@ fn map_node(n: &Node, w: &Warper) -> Node {
}
}
NodeKind::Mesh(m) => m.map(w.f),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
if let Some(d) = &deeper {
return map_node(&expanded_group_hooks(n, w.hooks), d);
}
@@ -1610,7 +1616,15 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
/// Is this one of the live kinds?
pub fn is_live(n: &Node) -> bool {
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_))
matches!(
n.kind,
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. }
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
@@ -1645,6 +1659,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(),
NodeKind::PlacedDocument(p) => p.art(n),
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
_ => vec![n.clone()],
}
}
@@ -1699,11 +1714,23 @@ pub fn subtree_has_live(n: &Node) -> bool {
any
}
/// Largest stroke outset in a subtree (for visual bounds of envelopes).
pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the
/// outsets in the art of the symbols its instances show count too
/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made
/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad
/// does.
pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxes>) -> f64 {
let mut o = 0.0f64;
for n in nodes {
n.walk(&mut |c| o = o.max(c.appearance.outset()));
n.walk(&mut |c| {
o = o.max(c.appearance.outset());
if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind)
&& let Some(art) = s.art(symbol)
{
let reach = if s.art_strokes() { art.reach } else { 0.0 };
o = o.max(reach + s.brush_pad(symbol));
}
});
}
o
}
@@ -1711,6 +1738,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
/// real distance from the centre line; an inner edge that would pass the centre stops there.
#[test]
fn arc_warp_is_round_in_real_proportions() {
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
let r0 = 2.0 / (b * PI);
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
for i in 0..=10 {
let x = -1.0 + 0.2 * f64::from(i);
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
let d = nx.hypot(ny * aspect - r0);
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
}
}
}
// At 100 % on a square the inner edge meets at the centre instead of folding over.
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
assert!(nx.abs() < 1e-12);
// A square bent less than that is what it always was.
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
+5 -1
View File
@@ -251,10 +251,14 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += ALLOC + size_of::<NodeExtras>();
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children),
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Intertwine { children } => vec_bytes(children),
NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::<BlendSpec>(),
NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::<EnvelopeKind>() + size_of::<Affine>(),
NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND,
+187 -26
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::BlendMode;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_geom::corners::cut_corners;
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
use crate::pattern::RepeatSpec;
use crate::symbols::{SYMBOL_SQUARE, SymbolBoxes};
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
@@ -485,6 +486,12 @@ pub enum NodeKind {
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
/// [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
/// [`crate::intertwine`]).
Intertwine {
children: Vec<Arc<Node>>,
},
}
fn yes() -> bool {
@@ -542,6 +549,9 @@ pub struct NodeExtras {
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
pub series_index: Option<u32>,
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
@@ -554,6 +564,12 @@ pub struct NodeExtras {
pub slice: Option<Box<crate::SliceOptions>>,
/// Object › Perspective: the perspective grid plane the object is attached to.
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
/// Dimension object: the live measurement it draws; its ordinary group children hold its
/// exportable appearance (Object › Dimensions, the Dimension tool).
pub dimension: Option<Box<crate::dimension::Dimension>>,
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
/// filled non-zero) where this object is drawn over the group's other members.
pub intertwine: Option<Box<PathData>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -563,11 +579,14 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
wrap: None,
graph: None,
series_index: None,
type_outlines: false,
shaper: None,
graphic_style: None,
attrs: None,
slice: None,
perspective: None,
dimension: None,
intertwine: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -691,6 +710,7 @@ impl Serialize for Node {
e.wrap.is_none(),
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
@@ -698,6 +718,8 @@ impl Serialize for Node {
e.slice.is_none(),
is_default(&self.bbox_angle),
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -725,6 +747,7 @@ impl Serialize for Node {
field!("wrap", &e.wrap);
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
@@ -732,6 +755,8 @@ impl Serialize for Node {
field!("slice", &e.slice);
field!("bbox_angle", &self.bbox_angle);
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
st.end()
}
}
@@ -771,6 +796,8 @@ struct NodeWire {
#[serde(default)]
series_index: Option<u32>,
#[serde(default)]
type_outlines: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
@@ -783,6 +810,10 @@ struct NodeWire {
bbox_angle: f64,
#[serde(default)]
perspective: Option<Box<crate::PerspectiveAttachment>>,
#[serde(default)]
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
}
impl From<NodeWire> for Node {
@@ -793,11 +824,14 @@ impl From<NodeWire> for Node {
wrap: w.wrap,
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
slice: w.slice,
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
};
Node {
id: w.id,
@@ -842,6 +876,38 @@ impl OpacityMask {
}
}
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as an opacity
/// mask's, whose luminance over the black outside the art is then the art's alpha.
pub fn whiten(n: &mut Node) {
let white = |p: &mut Paint| match p {
Paint::Solid { color, swatch, tint } => {
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
}
}
Paint::None | Paint::Pattern { .. } => {}
};
for item in &mut n.appearance.items {
white(item.paint_mut());
}
match &mut n.kind {
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
white(&mut r.style.fill);
white(&mut r.style.stroke);
}),
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch {
whiten(Arc::make_mut(c));
}
}
}
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
Self {
@@ -883,7 +949,8 @@ impl Node {
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
| NodeKind::Repeat(RepeatSpec { source: children, .. })
| NodeKind::Intertwine { children } => Some(children),
_ => None,
}
}
@@ -894,7 +961,8 @@ impl Node {
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
| NodeKind::Repeat(RepeatSpec { source: children, .. })
| NodeKind::Intertwine { children } => Some(children),
_ => None,
}
}
@@ -962,6 +1030,7 @@ impl Node {
NodeKind::Mesh(_) => "Mesh",
NodeKind::Repeat(r) => r.kind.label(),
NodeKind::PlacedDocument(_) => "Placed Document",
NodeKind::Intertwine { .. } => "Intertwine",
}
}
/// Name shown in the Layers panel: explicit name or `<Kind>`.
@@ -977,6 +1046,12 @@ impl Node {
_ => format!("<{}>", self.kind_label()),
}
}
/// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID.
pub fn identify_label(&self, xml_id: bool) -> String {
let name = self.display_name();
if xml_id { crate::xml_id::from_object_name(&name) } else { name }
}
/// Path data for path nodes.
pub fn path_data(&self) -> Option<&PathData> {
match &self.kind {
@@ -1004,79 +1079,147 @@ impl Node {
}
/// A quick box round everything the object can paint, for culling clicks: paths and compound
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
/// ([`Appearance::outset`]) instead of measuring along their shape.
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
/// measured as in [`Node::visual_bounds`].
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
/// grown by the farthest a stroke in the art can reach.
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
symbols.quickly(|| self.reach(Some(symbols)))
}
/// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given.
pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
self.geometric(symbols).map(|b| b.inflate(o, o))
}
_ => self.visual_bounds(),
_ => self.visual(symbols),
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A
/// symbol instance is measured as its transform applied to the 20 × 20 square around the origin
/// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures
/// its art).
pub fn geometric_bounds(&self) -> Option<Rect> {
self.geometric(None)
}
/// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to
/// its symbol's art box from `symbols`. An instance is measured as the square when its symbol
/// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and
/// when symbols nest 32 deep in its art.
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.geometric(Some(symbols))
}
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
/// is given.
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
let union = |nodes: &[Arc<Node>]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols)));
match &self.kind {
NodeKind::Path { path, .. } => path.bounds(),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric_bounds())
children.first().and_then(|c| c.geometric(symbols))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
NodeKind::Text(t) => self.projected(t.bounds()),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
NodeKind::Blend { children, spec } => {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
NodeKind::SymbolInstance { symbol, xf } => {
let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric);
self.projected(Some(xf.transform_rect_bbox(art)))
}
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
// The members drawn over the others are drawn where they are.
NodeKind::Intertwine { children } => union(children),
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
/// ([`Node::visual_bounds_with`] measures its art).
pub fn visual_bounds(&self) -> Option<Rect> {
self.visual(None)
}
/// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from
/// `symbols`, the strokes in it included. An instance that only moves its art is measured as
/// the art's visual box moved. Any other instance is measured as its art transformed by it with
/// the strokes at their own weight, as the renderer draws it, and never smaller than its
/// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as
/// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of
/// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In
/// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's
/// geometric box placed by its transform, grown by its own strokes.
pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.visual(Some(symbols))
}
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
/// given.
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
let clip = children.first()?;
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
clip.visual_bounds()
clip.visual(symbols)
} else {
clip.geometric_bounds()
clip.geometric(symbols)
}
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)))
.map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
let o = crate::live::max_outset(children);
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
let o = crate::live::max_outset(children, symbols);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
}
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
let o = crate::live::max_outset(content);
self.geometric_bounds().map(|b| b.inflate(o, o))
let o = crate::live::max_outset(content, symbols);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let b = self.geometric_bounds()?;
let b = self.geometric(symbols)?;
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
}
NodeKind::SymbolInstance { symbol, xf } => {
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
Some((s, art)) => {
let placed = xf.transform_rect_bbox(art.geometric);
match s.placed_visual(symbol, *xf) {
Some(v) => v.union(placed),
None => placed.inflate(art.reach, art.reach),
}
}
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
};
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
_ => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
self.geometric(symbols).map(|b| b.inflate(o, o))
}
}
}
@@ -1087,6 +1230,12 @@ impl Node {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
// A dimension's measurement moves with it.
if let Some(e) = self.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
@@ -1125,7 +1274,10 @@ impl Node {
}
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Intertwine { children } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
@@ -1179,6 +1331,10 @@ impl Node {
{
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
}
// The area where an Intertwine member is drawn over the others moves with it.
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
area.transform(a);
}
}
/// Visit this node and all descendants depth first (paint order).
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
@@ -1307,7 +1463,7 @@ impl Node {
let frame = shapes::rectangle(p.natural()).transformed(p.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
}
}
@@ -1722,6 +1878,9 @@ mod kind_de {
Mesh(GradientMesh),
Repeat(RepeatSpec),
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
Intertwine {
children: Vec<Arc<Node>>,
},
}
impl<'de> Deserialize<'de> for NodeKind {
@@ -1851,6 +2010,7 @@ mod tests {
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
e.series_index = Some(2);
e.type_outlines = true;
e.graphic_style = Some(3);
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
@@ -1889,6 +2049,7 @@ mod tests {
"mask",
"trace",
"series_index",
"type_outlines",
"kind",
"graphic_style",
"attrs",
+26 -8
View File
@@ -5,7 +5,7 @@
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
/// Angles closer than this (degrees) are the same.
const EPS_DEG: f64 = 1e-6;
@@ -86,11 +86,12 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
impl Node {
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it.
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
/// without changing it, symbol instances measured by their art from `symbols`
/// ([`Node::geometric_bounds_with`]).
pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: false } => {
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
// The group's own strokes paint around its members (as in `visual_bounds`).
let o = if visual { self.appearance.outset() } else { 0.0 };
b.map(|b| b.inflate(o, o))
@@ -98,12 +99,12 @@ impl Node {
NodeKind::Group { children, clip: true } => {
let clip = children.first()?;
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
clip.bounds_in(a, visual && stroked)
clip.bounds_in(symbols, a, visual && stroked)
}
_ => {
let mut n = self.clone();
n.transform(a, false);
if visual { n.visual_bounds() } else { n.geometric_bounds() }
if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) }
}
}
}
@@ -117,14 +118,16 @@ impl Document {
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
}
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
/// geometric bounds, or `visual` with their strokes.
/// geometric bounds, or `visual` with their strokes, symbol instances measured by their art
/// ([`Document::bounds_of`]).
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
let angle = self.bbox_angle(ids);
if angle == 0.0 {
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
let symbols = self.symbol_boxes();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?;
Some(OrientedBox { angle, rect })
}
}
@@ -192,4 +195,19 @@ mod tests {
d.move_node(id, Some(g), 0).unwrap();
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
}
#[test]
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
let b = d.oriented_bounds(&[id], false).unwrap();
assert!(close(b.angle, 30.0));
assert!(close(b.rect.width(), 40.0) && close(b.rect.height(), 10.0), "the bar, not the 20 × 20 square: {b:?}");
assert!(b.center().distance(c) < 1e-9);
}
}
+6 -2
View File
@@ -516,8 +516,12 @@ impl RepeatSpec {
}
/// Geometric bounds of all instances.
pub fn bounds(&self) -> Option<Rect> {
let b = self.source_bounds()?;
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
self.copies_bounds(self.source_bounds()?)
}
/// The bounds of all instances of a source measuring `source`, each placed by its transform
/// ([`Self::transforms`]).
pub(crate) fn copies_bounds(&self, source: Rect) -> Option<Rect> {
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
+30 -2
View File
@@ -6,6 +6,22 @@ use std::sync::Arc;
use crate::ImageBlob;
/// The most memory the decoded RGBA pixels of one image may take: the allowance the importers
/// and the renderer decode within (a 60 in sheet at 300 ppi is ~475 MB). The web app has 4 GB to
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) * 4 > max {
return None;
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
}
impl ImageBlob {
/// A PNG blob of `png` bytes.
pub fn png(png: Vec<u8>) -> Self {
@@ -21,7 +37,7 @@ impl ImageBlob {
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
/// image can't be decoded or no pixel changed.
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
let mut img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
let mut changed = false;
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
@@ -67,7 +83,7 @@ impl ImageBlob {
LAST.with_borrow_mut(|last| match last {
Some((k, img)) if *k == key => Some(img.clone()),
_ => {
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
let img = Arc::new(rgba_within(&self.bytes, MAX_RASTER_ALLOC)?);
*last = Some((key, img.clone()));
Some(img)
}
@@ -115,6 +131,18 @@ mod tests {
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
/// the decoder's own limit lets through is four times that as RGBA.
#[test]
fn pixels_past_the_allowance_are_not_decoded() {
let grey = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
let mut png = Vec::new();
grey.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(rgba_within(&png, 31).is_none(), "8 grey bytes are 32 as RGBA");
assert_eq!(rgba_within(&png, 32).map(|i| i.dimensions()), Some((4, 2)));
assert!(rgba_within(b"not an image", u64::MAX).is_none());
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
+5
View File
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
Arc::ptr_eq(&a.0, &b.0)
}
/// A weak reference to the value: it keeps the allocation, so the value's address names it
/// while the reference is kept, but not the value.
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
Arc::downgrade(&this.0)
}
}
impl<T> std::ops::Deref for Shared<T> {
+33 -9
View File
@@ -109,10 +109,11 @@ impl Document {
}
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
/// paint (and the paths to them) are copied. Returns the number of changed paints.
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
/// `f(colour, link, tint)` may change them and returns true when it did
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
@@ -152,11 +153,12 @@ impl Document {
}
impl Document {
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
/// Visit every paint held by the art (opacity masks included), symbol definitions, pattern
/// tiles and graphic styles (fills, strokes and text runs), and the colour of every
/// gradient-mesh point as a solid paint.
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
n.walk(&mut |m| {
walk_with_masks(n, &mut |m| {
node_paints(m).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
@@ -224,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
@@ -233,7 +235,7 @@ fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
}
/// Mutable [`node_paints`], in the same order.
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
@@ -280,9 +282,31 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
}
}
}
// Opacity mask art lives outside the tree, but its colours are the object's too (#1735).
if let Some(mask) = n.extras().mask.as_deref()
&& let Some((new, k)) = map_node(&mask.art, f)
{
let m = out.get_or_insert_with(|| n.clone());
if let Some(slot) = m.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
slot.art = Arc::new(new);
count += k;
}
}
out.map(|m| (m, count))
}
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
/// object, before its children).
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
f(n);
if let Some(mask) = n.extras().mask.as_deref() {
walk_with_masks(&mask.art, f);
}
for c in n.children().into_iter().flatten() {
walk_with_masks(c, f);
}
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
+36
View File
@@ -0,0 +1,36 @@
//! Symbol Options › Registration: the 9-point grid (0 = top-left … 4 = centre … 8 = bottom-right)
//! that `symbol.place` uses as the point that lands on the click.
use vectorcraft_geom::Vec2;
/// Document unknown key: `{ symbolName: 0..8 }` for each symbol's registration point.
pub const SYMBOL_REGISTRATION: &str = "symbolRegistration";
/// Index on the 9-point grid (row-major, 4 = centre).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SymbolRegistration(pub u8);
impl Default for SymbolRegistration {
fn default() -> Self {
Self(4)
}
}
impl SymbolRegistration {
pub const CENTER: Self = Self(4);
pub fn from_u64(v: u64) -> Option<Self> {
(v <= 8).then_some(Self(v as u8))
}
pub fn is_center(self) -> bool {
self.0 == 4
}
/// Offset of this registration point from the symbol's centre, in natural-size units
/// (right +, down +).
pub fn offset_from_center(self, w: f64, h: f64) -> Vec2 {
let (col, row) = (self.0 % 3, self.0 / 3);
Vec2::new((f64::from(col) - 1.0) * (w * 0.5), (f64::from(row) - 1.0) * (h * 0.5))
}
}
+565
View File
@@ -0,0 +1,565 @@
//! Symbol instances measured by their symbol's art: [`SymbolBoxes`] looks each symbol's art up in
//! the document and measures it once, and an instance's bounds are its art's box placed by the
//! instance's transform ([`Node::geometric_bounds_with`]).
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use vectorcraft_geom::{Affine, Rect};
use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
/// The box a symbol instance is measured by when its symbol's art has no box
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
/// that symbols made in the app keep their art in.
pub(crate) const SYMBOL_SQUARE: Rect = Rect::new(-SYMBOL_HALF, -SYMBOL_HALF, SYMBOL_HALF, SYMBOL_HALF);
/// Instances of a symbol whose art holds symbols nested this many deep are measured by
/// [`SYMBOL_SQUARE`] instead of their art.
const MAX_DEPTH: usize = 32;
/// Most effect reaches one set of boxes measures for transforms that place the art of symbols
/// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]).
const MAX_REACHES: usize = 4096;
/// A symbol's art measured in symbol space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ArtBox {
/// The art's geometric bounds.
pub(crate) geometric: Rect,
/// The art's visual bounds (its strokes included), never smaller than `geometric`.
pub(crate) visual: Rect,
/// The farthest any stroke in the art can paint from its path ([`crate::Appearance::outset`]).
pub(crate) reach: f64,
/// Whether the art, or the art of a symbol an instance in it shows, has a visible effect.
pub(crate) effects: bool,
/// The widest stroke of the objects with a visible brushed stroke in the art, or in the art
/// of a symbol an instance in it shows: `None` when no object there has one.
pub(crate) brush: Option<f64>,
}
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
/// [`Node::visual_bounds_with`] and [`Node::reach_bounds_with`] measure symbol instances by. Each
/// symbol's art is measured the first time an instance of it is measured; make a new one after the
/// symbols change.
pub struct SymbolBoxes<'a> {
doc: &'a Document,
/// The art boxes measured so far, by symbol name (`None` for a symbol whose instances are
/// measured by [`SYMBOL_SQUARE`]).
measured: RefCell<HashMap<String, Option<ArtBox>>>,
/// How deep symbols nest in the art of each symbol checked so far, by symbol name
/// ([`Self::depth`]).
depths: RefCell<HashMap<String, Option<usize>>>,
/// Set while symbol art or a click's box is measured, and always in boxes made by
/// [`Self::quick`]: instances are then measured by their art boxes alone
/// ([`Self::placed_visual`] returns `None`).
quick: Cell<bool>,
/// Set in boxes made by [`Self::with_brush_art`], and cleared while symbol art is measured
/// ([`Self::brush_pad`]).
brush_art: Cell<bool>,
/// Cleared in boxes made by [`Self::without_art_strokes`], and set while symbol art is
/// measured ([`Self::art_strokes`]).
art_strokes: Cell<bool>,
/// The effect reaches measured so far, by symbol name and the bits of the linear part of the
/// transform that places the art ([`Self::effects_reach`]).
reaches: RefCell<HashMap<(String, [u64; 4]), f64>>,
/// How many of them are of symbols whose art holds instances.
holders: Cell<usize>,
/// The farthest effect reach measured so far for each symbol.
farthest: RefCell<HashMap<String, f64>>,
/// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`]
/// ([`Self::unplaced_reach`]).
unplaced: RefCell<HashMap<String, f64>>,
}
impl Document {
/// [`SymbolBoxes`] for this document's symbols, none measured yet.
pub fn symbol_boxes(&self) -> SymbolBoxes<'_> {
SymbolBoxes {
doc: self,
measured: RefCell::default(),
depths: RefCell::default(),
quick: Cell::new(false),
brush_art: Cell::new(false),
art_strokes: Cell::new(true),
reaches: RefCell::default(),
holders: Cell::new(0),
farthest: RefCell::default(),
unplaced: RefCell::default(),
}
}
}
impl<'a> SymbolBoxes<'a> {
/// The document whose symbols these are.
pub fn document(&self) -> &'a Document {
self.doc
}
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
/// art box alone. An instance that scales or turns stroked art then measures a little larger
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it.
pub fn quick(self) -> Self {
self.quick.set(true);
self
}
/// Makes these boxes grow the visual box of each symbol instance whose art holds brushed
/// strokes by as far as their brush art can paint past it ([`Self::brush_pad`]). The visual
/// boxes of the groups, layers and live objects that hold such an instance grow with it.
pub fn with_brush_art(self) -> Self {
self.brush_art.set(true);
self
}
/// Makes these boxes measure the visual box of each symbol instance without the strokes in
/// its art: the art's geometric box placed by the instance's transform, grown by the
/// instance's own strokes. The strokes in the art keep their weight when the instance scales.
/// The visual boxes of the groups, layers and live objects that hold an instance do not include
/// those strokes either.
pub fn without_art_strokes(self) -> Self {
self.art_strokes.set(false);
self
}
/// Whether instances are measured with the strokes in their art: false in boxes made by
/// [`Self::without_art_strokes`], except while symbol art is measured.
pub(crate) fn art_strokes(&self) -> bool {
self.art_strokes.get()
}
/// The art box of the symbol named `name`, with the instances in the art measured by their own
/// art boxes. Returns `None` when [`Self::depth`] is `None` and when the art has no finite
/// bounds: instances of the symbol are then measured by [`SYMBOL_SQUARE`].
pub(crate) fn art(&self, name: &str) -> Option<ArtBox> {
if let Some(b) = self.measured.borrow().get(name) {
return *b;
}
let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten();
self.measured.borrow_mut().insert(name.to_string(), b);
b
}
/// Runs `measure` on the art of the symbol named `name`. Returns `None` when [`Self::depth`]
/// is `None`, without running it.
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
self.depth(name)?;
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
Some(measure(&sym.art))
}
/// The geometric box of the art of the symbol named `name` in symbol space, with the instances
/// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of
/// the symbol are then measured by [`SYMBOL_SQUARE`].
pub fn art_bounds(&self, name: &str) -> Option<Rect> {
self.art(name).map(|a| a.geometric)
}
/// Whether the art of the symbol named `name`, or of a symbol an instance in it shows, has a
/// visible effect (raster or geometry, the object's own or a fill's or stroke's). False when
/// [`Self::art`] is `None`.
pub fn has_effects(&self, name: &str) -> bool {
self.art(name).is_some_and(|a| a.effects)
}
/// How far the effects in the art of the symbol named `name` paint past that art when a
/// transform with the linear part `linear` places it, as `measure` measures it for a linear
/// part. `measure` runs once for each symbol and linear part. For symbols whose art holds
/// instances, it runs [`MAX_REACHES`] times at most for these boxes. After that, a linear part
/// not measured yet takes the reach with no transform, times √2 and the larger of 1 and the
/// linear part's Frobenius norm, or the farthest reach measured for the symbol when that is
/// farther; `measure` runs once more for each of those symbols whose reach with no transform
/// was not measured yet. The estimate holds for effects that paint a fixed distance past the
/// art, or a distance in proportion to its size.
pub fn effects_reach(&self, name: &str, linear: [f64; 4], measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
let key = (name.to_string(), linear.map(f64::to_bits));
if let Some(r) = self.reaches.borrow().get(&key) {
return *r;
}
let holder = self.depth(name) != Some(0);
if holder && self.holders.get() >= MAX_REACHES {
let norm = linear.iter().map(|v| v * v).sum::<f64>().sqrt();
let estimate = self.unplaced_reach(name, measure) * std::f64::consts::SQRT_2 * norm.max(1.0);
return self.farthest.borrow().get(name).map_or(estimate, |f| f.max(estimate));
}
if holder {
self.holders.set(self.holders.get() + 1);
}
let r = measure(linear);
self.reaches.borrow_mut().insert(key, r);
let mut farthest = self.farthest.borrow_mut();
let f = farthest.entry(name.to_string()).or_insert(r);
*f = f.max(r);
r
}
/// [`Self::effects_reach`] with no transform, measured once for each symbol, also past
/// [`MAX_REACHES`].
fn unplaced_reach(&self, name: &str, measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
const IDENTITY: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
if let Some(r) = self.reaches.borrow().get(&(name.to_string(), IDENTITY.map(f64::to_bits))) {
return *r;
}
if let Some(r) = self.unplaced.borrow().get(name) {
return *r;
}
let r = measure(IDENTITY);
self.unplaced.borrow_mut().insert(name.to_string(), r);
r
}
/// The widest stroke of the objects with a visible brushed stroke in the art of the symbol
/// named `name`, or in the art of a symbol an instance in it shows. `None` when no object there
/// has one, and when [`Self::art`] is `None`.
pub fn brush_width(&self, name: &str) -> Option<f64> {
self.art(name)?.brush
}
/// How far the visual box of an instance of the symbol named `name` grows in boxes made by
/// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that
/// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path
/// (`brush_fx::cull_bounds` in vectorcraft-render). 0 in other boxes, while symbol art is
/// measured, and when the art has no brushed stroke.
pub(crate) fn brush_pad(&self, name: &str) -> f64 {
if !self.brush_art.get() {
return 0.0;
}
self.brush_width(name).map_or(0.0, |w| 16.0 * w.max(1.0) + 8.0)
}
/// How deep symbols nest in the art of the symbol named `name`: 0 when the art holds no
/// instance of a symbol in the document, 1 when the symbols its instances show hold none, and
/// so on. Returns `None` when the document has no such symbol, when the symbol or a symbol
/// nested in its art holds an instance of itself at any depth, and when symbols nest
/// [`MAX_DEPTH`] deep: instances of the symbol are then measured by [`SYMBOL_SQUARE`]. The
/// answer depends only on the document's symbols.
fn depth(&self, name: &str) -> Option<usize> {
if let Some(d) = self.depths.borrow().get(name) {
return *d;
}
let mut learned = HashMap::new();
let d = self.find_depth(name, &mut learned);
let mut depths = self.depths.borrow_mut();
depths.extend(learned);
depths.insert(name.to_string(), d);
d
}
/// Finds [`Self::depth`] for the symbol named `name` by walking the symbols its art's instances
/// show, depth first, without recursing. `learned` collects what the walk finds out about the
/// other symbols it reaches: the depth of each one it walks to the end, and `None` for those on
/// the way down to a symbol that holds an instance of itself or nests too deep.
fn find_depth(&self, name: &str, learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
let art = |n: &str| self.doc.symbols.iter().find(|s| s.name == n).map(|s| &s.art);
// The symbols on the way down from `name`: each with the symbols its art shows that are
// still to be walked, and the deepest nesting found under it so far.
let mut path: Vec<(String, Vec<String>, usize)> = vec![(name.to_string(), shown(self.doc, art(name)?), 0)];
loop {
let (_, todo, _) = path.last_mut()?;
let Some(next) = todo.pop() else {
// Everything under it is walked.
let (done, _, d) = path.pop()?;
if d >= MAX_DEPTH {
learned.insert(done, None);
return give_up(&path, learned);
}
learned.insert(done, Some(d));
let Some((_, _, up)) = path.last_mut() else { return Some(d) };
*up = (*up).max(d + 1);
continue;
};
let known = learned.get(&next).copied().or_else(|| self.depths.borrow().get(&next).copied());
match known {
Some(Some(d)) => {
if let Some((_, _, deepest)) = path.last_mut() {
*deepest = (*deepest).max(d + 1);
}
}
// It holds an instance of itself or nests too deep, and so does every symbol on
// the way down to it.
Some(None) => return give_up(&path, learned),
// A symbol that holds an instance of itself.
None if path.iter().any(|(n, ..)| *n == next) => return give_up(&path, learned),
// Symbols nested MAX_DEPTH deep under `name`.
None if path.len() >= MAX_DEPTH => return None,
None => {
if let Some(a) = art(&next) {
let todo = shown(self.doc, a);
path.push((next, todo, 0));
}
}
}
}
}
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
/// its strokes at their own weight, the instances in it measured by their art boxes. `None`
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
/// click's box is measured.
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
if self.quick.get() {
return None;
}
self.measure_art(name, |art| {
let mut art = (**art).clone();
art.transform(xf, false);
self.in_art(|| art.visual(Some(self)))
})
.flatten()
}
/// Runs `f` with instances measured by their art boxes alone.
pub(crate) fn quickly<R>(&self, f: impl FnOnce() -> R) -> R {
let was = self.quick.replace(true);
let r = f();
self.quick.set(was);
r
}
/// Runs `f` as symbol art is measured: instances measured by their art boxes alone, with the
/// strokes in their art, and without [`Self::brush_pad`], which the instance that shows the
/// art adds once.
fn in_art<R>(&self, f: impl FnOnce() -> R) -> R {
let pad = self.brush_art.replace(false);
let strokes = self.art_strokes.replace(true);
let r = self.quickly(f);
self.brush_art.set(pad);
self.art_strokes.set(strokes);
r
}
}
/// Records every symbol on `path` in `learned` as one whose instances are measured by
/// [`SYMBOL_SQUARE`]. Returns `None`.
fn give_up(path: &[(String, Vec<String>, usize)], learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
for (n, ..) in path {
learned.insert(n.clone(), None);
}
None
}
/// The names of the symbols of `doc` that the instances in `art` show, each once.
fn shown(doc: &Document, art: &Node) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = vec![];
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& seen.insert(symbol.as_str())
&& doc.symbols.iter().any(|s| s.name == *symbol)
{
names.push(symbol.clone());
}
});
names
}
/// Measures symbol art `art`, with the instances in it measured by `symbols`.
fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
let geometric = art.geometric(Some(symbols)).filter(finite)?;
let visual = art.visual(Some(symbols)).filter(finite).map_or(geometric, |v| v.union(geometric));
let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols));
let mut effects = false;
let mut brush: Option<f64> = None;
art.walk(&mut |n| {
let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter()));
effects |= own.any(|e| e.visible);
let strokes = || n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None });
if strokes().any(|s| s.visible && s.brush.is_some()) {
brush = Some(strokes().fold(brush.unwrap_or(0.0), |w, s| w.max(s.width)));
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
effects |= symbols.has_effects(symbol);
if let Some(w) = symbols.brush_width(symbol) {
brush = Some(brush.map_or(w, |b| b.max(w)));
}
}
});
Some(ArtBox { geometric, visual, reach, effects, brush })
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::shapes;
use super::*;
use crate::{Appearance, Symbol};
/// A rectangle filled black, with a `stroke` pt black stroke (0: none).
fn rect(d: &mut Document, r: Rect, stroke: f64) -> Node {
let paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None };
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), paint, stroke))
}
fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Node {
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
}
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
}
#[test]
fn an_instance_measures_as_its_art_placed_by_its_transform() {
let mut d = Document::new(200.0, 200.0);
let bar = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 10.0), 2.0);
symbol(&mut d, "Bar", vec![bar]);
let moved = instance(&mut d, "Bar", Affine::translate((20.0, 30.0)));
let scaled = instance(&mut d, "Bar", Affine::scale(3.0));
let s = d.symbol_boxes();
assert_eq!(moved.geometric_bounds_with(&s), Some(Rect::new(20.0, 30.0, 40.0, 40.0)));
// Without the document: the 20 × 20 square around the origin, moved.
assert_eq!(moved.geometric_bounds(), Some(Rect::new(10.0, 20.0, 30.0, 40.0)));
// The 2 pt stroke paints 1 pt around the bar.
assert_eq!(moved.visual_bounds_with(&s), Some(Rect::new(19.0, 29.0, 41.0, 41.0)));
assert_eq!(moved.reach_bounds_with(&s), moved.visual_bounds_with(&s));
// Scaled 3 times: the geometry scales, the stroke keeps its weight.
assert_eq!(scaled.geometric_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0)));
assert_eq!(scaled.visual_bounds_with(&s), Some(Rect::new(-1.0, -1.0, 61.0, 31.0)));
// A click's box: the geometric box grown by the farthest the stroke can reach (a mitered
// corner up to its miter limit).
let reach = d.symbols[0].art.children().unwrap()[0].appearance.outset();
assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach)));
}
#[test]
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
let mut d = Document::new(200.0, 200.0);
let missing = instance(&mut d, "Nowhere", Affine::translate((50.0, 50.0)));
assert_eq!(missing.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
// A symbol whose art holds an instance of itself 100 pt to the right, and a symbol whose
// art holds an instance of that one.
let square = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0);
let inner = instance(&mut d, "Loop", Affine::translate((100.0, 0.0)));
symbol(&mut d, "Loop", vec![square.clone(), inner]);
let looped = instance(&mut d, "Loop", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![square, looped]);
let outer = instance(&mut d, "Loop", Affine::IDENTITY);
let doubled = instance(&mut d, "Loop", Affine::scale(2.0));
let holder = instance(&mut d, "Holder", Affine::translate((50.0, 50.0)));
let s = d.symbol_boxes();
assert_eq!(outer.geometric_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
assert_eq!(outer.visual_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
assert_eq!(doubled.visual_bounds_with(&s), Some(Rect::new(-20.0, -20.0, 20.0, 20.0)));
assert_eq!(holder.geometric_bounds_with(&s), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
}
#[test]
fn nested_instances_measure_by_their_own_symbols_art() {
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
symbol(&mut d, "Dot", vec![dot]);
let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0)));
let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0)));
symbol(&mut d, "Pair", vec![right, below]);
let pair = instance(&mut d, "Pair", Affine::translate((100.0, 100.0)));
assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0)));
}
#[test]
fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() {
// A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to
// the right: symbols nest 39 deep in Link 0's art and 31 deep in Link 8's.
let mut d = Document::new(200.0, 200.0);
for i in 0..40 {
let unit = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
let next = instance(&mut d, &format!("Link {}", i + 1), Affine::translate((1.0, 0.0)));
symbol(&mut d, &format!("Link {i}"), if i < 39 { vec![unit, next] } else { vec![unit] });
}
let link0 = instance(&mut d, "Link 0", Affine::IDENTITY);
let link7 = instance(&mut d, "Link 7", Affine::IDENTITY);
let link8 = instance(&mut d, "Link 8", Affine::IDENTITY);
let (square, chain) = (Some(Rect::new(-10.0, -10.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 32.0, 1.0)));
let s = d.symbol_boxes();
assert_eq!(link0.geometric_bounds_with(&s), square);
assert_eq!(link8.geometric_bounds_with(&s), chain);
assert_eq!(link7.geometric_bounds_with(&s), square);
let s = d.symbol_boxes();
assert_eq!(link8.geometric_bounds_with(&s), chain);
assert_eq!(link0.geometric_bounds_with(&s), square);
}
#[test]
fn brushed_strokes_are_found_in_the_art_and_in_nested_symbols_art() {
let mut d = Document::new(200.0, 200.0);
let mut line = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 3.0);
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
symbol(&mut d, "Brushed", vec![line]);
let plain = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 5.0);
let inner = instance(&mut d, "Brushed", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![plain.clone(), inner]);
symbol(&mut d, "Plain", vec![plain]);
let s = d.symbol_boxes();
assert_eq!(s.brush_width("Brushed"), Some(3.0));
assert_eq!(s.brush_width("Holder"), Some(3.0), "the 5 pt stroke has no brush");
assert_eq!(s.brush_width("Plain"), None);
}
#[test]
fn boxes_with_brush_art_grow_instances_of_brushed_art_once() {
let mut d = Document::new(200.0, 200.0);
// A line from (0, 40) to (0, 60) with a 1 pt brushed stroke, and a symbol whose art holds an
// instance of it.
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
symbol(&mut d, "Nib", vec![line]);
let inner = instance(&mut d, "Nib", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![inner]);
let nib = instance(&mut d, "Nib", Affine::translate((-5.0, 0.0)));
let holder = instance(&mut d, "Holder", Affine::translate((-5.0, 0.0)));
let group = Node::group(d.alloc_id(), vec![Arc::new(nib.clone())]);
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(crate::RepeatSpec::mirror(vec![Arc::new(nib.clone())], 90.0, 10.0)));
let (plain, brushed) = (d.symbol_boxes(), d.symbol_boxes().with_brush_art());
// 16 × 1 + 8 for the 1 pt stroke.
for n in [&nib, &holder, &group, &repeat] {
let b = n.visual_bounds_with(&plain).unwrap();
assert_eq!(n.visual_bounds_with(&brushed), Some(b.inflate(24.0, 24.0)), "{}", n.kind_label());
}
assert_eq!(nib.geometric_bounds_with(&brushed), nib.geometric_bounds_with(&plain));
}
#[test]
fn effect_reaches_are_measured_once_for_each_linear_part_and_up_to_a_limit() {
// `Leaf` holds a square; `Holder` and `Other` each hold an instance of `Leaf`.
let mut d = Document::new(10.0, 10.0);
let square = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
symbol(&mut d, "Leaf", vec![square]);
for name in ["Holder", "Other"] {
let leaf = instance(&mut d, "Leaf", Affine::IDENTITY);
symbol(&mut d, name, vec![leaf]);
}
let s = d.symbol_boxes();
let runs = &Cell::new(0);
// A measure that finds `reach` for every linear part.
let measure = |reach: f64| {
move |_: [f64; 4]| {
runs.set(runs.get() + 1);
reach
}
};
let unscaled = [1.0, 0.0, 0.0, 1.0];
assert_eq!(s.effects_reach("Holder", unscaled, &measure(3.0)), 3.0);
assert_eq!(s.effects_reach("Holder", unscaled, &measure(5.0)), 3.0, "measured once");
assert_eq!(runs.get(), 1);
for i in 1..MAX_REACHES {
s.effects_reach("Holder", [1.0, 0.0, 0.0, 1.0 + i as f64], &measure(1.0));
}
assert_eq!(runs.get(), MAX_REACHES);
// Past the limit: the reach with no transform, times √2 and the larger of 1 and the
// Frobenius norm (√8 for a scale of 2, √2 with no transform, √0.5 for a scale of 0.5). That
// reach is measured once more for a symbol not measured before.
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(near(s.effects_reach("Holder", [2.0, 0.0, 0.0, 2.0], &measure(9.0)), 12.0));
assert!(near(s.effects_reach("Other", unscaled, &measure(9.0)), 18.0));
assert!(near(s.effects_reach("Other", [0.5, 0.0, 0.0, 0.5], &measure(7.0)), 9.0 * std::f64::consts::SQRT_2));
assert_eq!(runs.get(), MAX_REACHES + 1);
// A symbol whose art holds no instance is measured past the limit too.
assert_eq!(s.effects_reach("Leaf", [2.0, 0.0, 0.0, 2.0], &measure(7.0)), 7.0);
assert_eq!(runs.get(), MAX_REACHES + 2);
}
}
+66 -3
View File
@@ -1,5 +1,7 @@
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names
//! and canvas readouts.
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit,
//! percentages in arithmetic, names and canvas readouts.
use proptest::prelude::*;
use super::Unit;
@@ -34,13 +36,54 @@ fn typed_units_win_over_the_fields_unit() {
assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0));
}
#[test]
fn a_percentage_is_a_share_of_the_value_before_it() {
let (mm, cm) = (72.0 / 25.4, 72.0 / 2.54);
for (u, s, want) in [
(Unit::Centimeters, "3cm*50%", 1.5 * cm),
(Unit::Points, "50pt+25%", 62.5),
(Unit::Points, "50pt-25%", 37.5),
(Unit::Millimeters, "10*50%", 5.0 * mm),
(Unit::Centimeters, "3cm/50%", 6.0 * cm),
(Unit::Points, "50 pt + 25 %", 62.5),
(Unit::Points, "10+20+50%", 45.0),
// A percentage added or taken away is a share of everything before it. A percentage factor
// is a share of the term before it.
(Unit::Points, "100pt+20pt-50%", 60.0),
(Unit::Points, "100pt-20pt+50%", 120.0),
(Unit::Points, "100pt+20%-10pt", 110.0),
(Unit::Points, "10+20*50%", 20.0),
(Unit::Points, "10*2+50%", 30.0),
(Unit::Points, "10*2/50%", 40.0),
(Unit::Points, "10+-50%", 5.0),
(Unit::Points, "50pt--25%", 62.5),
(Unit::Centimeters, "3cm*-50%", -1.5 * cm),
] {
let got = u.parse(s).unwrap_or_else(|| panic!("{u:?} can't read `{s}`"));
assert!((got - want).abs() < 1e-9, "{u:?}: `{s}` → {got}, want {want}");
}
}
#[test]
fn odd_text_reads_as_nothing_without_panicking() {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+", "50%", "%", "5%%", "%5", "1/0%", "10+%", "10*5pt%"] {
assert_eq!(Unit::Millimeters.parse(s), None, "{s}");
}
}
proptest! {
/// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value
/// or as nothing and does not panic.
#[test]
fn arithmetic_text_reads_as_a_finite_value_or_nothing(s in "[0-9.+*/% ptxinmcfyd-]{0,24}") {
for u in Unit::ALL {
if let Some(v) = u.parse(&s) {
prop_assert!(v.is_finite(), "{u:?}: `{s}` → {v}");
}
}
}
}
#[test]
fn units_are_named_by_label_key_or_suffix() {
for u in Unit::ALL {
@@ -66,3 +109,23 @@ fn readouts_show_two_decimals() {
assert_eq!((Unit::Inches.number(595.2756), Unit::Points.number(595.2756)), ("8.2677".into(), "595.276".into()));
assert_eq!(Unit::Points.number(-0.00001), "0");
}
#[test]
fn a_negative_operand_after_an_operator_is_a_sign() {
assert_eq!(Unit::Points.parse("10*-2"), Some(-20.0));
assert_eq!(Unit::Points.parse("-10/-2"), Some(5.0));
assert_eq!(Unit::Points.parse("5--3"), Some(8.0));
assert_eq!(Unit::Points.parse("5 - -3"), Some(8.0));
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
assert_eq!(Unit::Points.parse("10-2"), Some(8.0));
assert_eq!(super::parse_number("90*-1"), Some(-90.0));
}
#[test]
fn mixed_mul_div() {
// `*` and `/` share one precedence level and run left to right.
assert_eq!(Unit::Points.parse("10/2*5"), Some(25.0));
assert_eq!(crate::parse_number("100*3/4"), Some(75.0));
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
}
+14
View File
@@ -734,7 +734,9 @@ impl PathAlign {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AreaOptions {
#[serde(deserialize_with = "de_rows_or_columns")]
pub rows: usize,
#[serde(deserialize_with = "de_rows_or_columns")]
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
@@ -765,6 +767,18 @@ impl Default for AreaOptions {
}
}
impl AreaOptions {
/// The most rows, and the most columns, area type can have.
pub const MAX_ROWS_COLUMNS: usize = 100;
}
/// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to
/// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`].
fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result<usize, D::Error> {
let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX);
Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS))
}
/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or
/// `{"shrinkText": {"minPercent": 50}}`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+35 -12
View File
@@ -17,15 +17,21 @@ pub enum VariableKind {
Text,
/// Whether bound objects show.
Visibility,
/// A linked image's file path (the pixels swap; the object's bounds stay).
Image,
/// A graph object's data (CSV of categories and series, as Graph Data uses).
Graph,
}
impl VariableKind {
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both).
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both), `image` or `graph`.
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
"visibility" | "visible" => Some(Self::Visibility),
"image" => Some(Self::Image),
"graph" => Some(Self::Graph),
_ => None,
}
}
@@ -35,6 +41,8 @@ impl VariableKind {
match self {
Self::Text => "text",
Self::Visibility => "visibility",
Self::Image => "image",
Self::Graph => "graph",
}
}
}
@@ -52,13 +60,23 @@ pub struct Variable {
pub enum DataValue {
Text(String),
Visible(bool),
/// Absolute path of the image file an image variable should show.
Image(String),
/// CSV of a graph's categories and series (same shape as Graph Data / `graph.setData`).
Graph(String),
}
impl DataValue {
/// Whether this value fits the variable's kind (a dataset may carry values for
/// variables of either kind; each binding takes what fits it).
pub fn fits(&self, kind: VariableKind) -> bool {
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
matches!(
(self, kind),
(Self::Text(_), VariableKind::Text)
| (Self::Visible(_), VariableKind::Visibility)
| (Self::Image(_), VariableKind::Image)
| (Self::Graph(_), VariableKind::Graph)
)
}
}
@@ -84,9 +102,10 @@ impl DataSet {
///
/// An object may be driven by several variables — the reference app binds the selected object
/// to the selected variable, one click at a time, and never says a second binding replaces the
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
/// defined, which is what makes two text variables on one object deterministic: the last one
/// wins, and `applied` counts both.
/// first — so a binding is a set. `dataset.select` applies the text values in the order the
/// variables are defined, which is what makes two text variables on one object deterministic:
/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the
/// last one defined wins too.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Variables {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -173,9 +192,10 @@ impl Document {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
}
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
/// or locked layer (the document's value is whatever it was left at).
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
/// document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.variable_target(id) {
return None;
@@ -184,14 +204,17 @@ impl Document {
match (kind, &node.kind) {
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
(VariableKind::Text, _) => None,
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
(VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())),
(VariableKind::Text | VariableKind::Image, _) => None,
}
}
/// The values a dataset captured right now: what each bound object holds, in the order the
/// variables are defined. `dataset.capture` writes these into a new row and
/// `dataset.update` into the active one; the Variables panel compares the active row with
/// them to tell whether the art still matches what it says.
/// `dataset.update` into the active one, for the variables that row holds (`all: true` adds
/// the rest); the Variables panel compares the active row with them to tell whether the art
/// still matches what it says.
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
let mut out = BTreeMap::new();
for v in &self.variables.variables {
+117
View File
@@ -0,0 +1,117 @@
//! Object names ↔ XML IDs (Preferences › Units › Identify Objects By).
//!
//! Illustrator's public documentation: with XML ID selected, the Layers panel shows and edits
//! names that conform to XML naming (letter, underscore or colon first; no spaces). Underscores
//! stand for spaces and `_xHH_` / `_xHHHH_` escape other characters, as in `.ai` `AI10_ArtUID`
//! values ([edit dynamic objects](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/edit-dynamic-objects.html)).
/// An object name from its XML id: `_` is a space and `_xHH_` / `_xHHHH_` a character; ids made
/// unique end with `_` and 20 or more digits (stripped).
pub fn to_object_name(id: &str) -> String {
let id = strip_unique(id);
let mut out = String::with_capacity(id.len());
let mut rest = id;
while let Some(c) = rest.chars().next() {
if c == '_' {
if let Some((ch, n)) = escaped(rest) {
out.push(ch);
rest = rest.get(n..).unwrap_or_default();
continue;
}
out.push(' ');
} else {
out.push(c);
}
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
if c == ' ' {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
/// Whether `id` is a usable XML ID for Identify Objects By › XML ID: non-empty, no whitespace,
/// starts with a letter, `_` or `:`, and otherwise letters, digits, `.`, `-`, `_` or `:`.
pub fn is_valid(id: &str) -> bool {
let id = id.trim();
if id.is_empty() || id.chars().any(char::is_whitespace) {
return false;
}
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_alphabetic() || first == '_' || first == ':') {
return false;
}
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
let end = body.find('_')?;
let hex = body.get(..end)?;
if hex.is_empty() || hex.len() > 6 {
return None;
}
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
Some((c, end + 3))
}
/// `id` without the `_000…_` suffix that makes it unique.
fn strip_unique(id: &str) -> &str {
let Some(body) = id.strip_suffix('_') else {
return id;
};
let Some(at) = body.rfind('_') else {
return id;
};
let digits = body.get(at + 1..).unwrap_or_default();
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_ai_style_ids() {
for (name, id) in [
("Layer 1", "Layer_1"),
("My Rect #2", "My_Rect__x23_2"),
("1abc (paren)", "_x31_abc__x28_paren_x29_"),
("Ünï 日本", "Ünï_日本"),
("a_b", "a_x5F_b"),
] {
assert_eq!(from_object_name(name), id, "encode {name}");
assert_eq!(to_object_name(id), name, "decode {id}");
}
assert_eq!(to_object_name("S_00000092429200007574234970000005974767392385616558_"), "S");
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
}
+1
View File
@@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-plugins = { workspace = true }
image = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+532 -9
View File
@@ -9,18 +9,26 @@
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
//! become images of their own).
//!
//! Type that Transform effects on it, or on its group or layer, only move and copy stays type in
//! each place when its characters are in solid colors and the copies do not overlap ([`moved_type`]
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_doc::color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
use crate::distort::{transform_move, transform_offsets};
use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_pathfinder, is_geometry, needs_outline, reshape,
has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -98,8 +106,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
// Type, images, symbol instances and live objects: reshaped through their outlines.
// Type, images, symbol instances and live objects: reshaped through their outlines. Type that
// Transform effects only move and copy stays type, moved to each place.
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
}
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
@@ -107,9 +119,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
if let Some(m) = evaluate_container(n) {
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
// fills' and strokes' art) get ids of their own.
if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) {
return Some(bake_pieces(d, m));
}
if let Some(ch) = n.children() {
@@ -159,6 +172,201 @@ pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::Ha
}
}
/// Most copies of one type object that stay type, the original included ([`moved_type`]).
const MAX_TYPE_COPIES: usize = 10_000;
/// Most bytes that the copies of one type object that stay type take together in the document's
/// JSON form, the original included ([`moved_type`]): its text, frame or path, wrap shapes and the
/// rest of the object, once per copy.
const MAX_TYPE_COPY_BYTES: usize = 20_000_000;
/// The params of geometry effects `fx` when there are some and they are all Transform effects that
/// only move ([`transform_move`]), in order; `None` otherwise.
fn transform_moves(fx: &[&Effect]) -> Option<Vec<serde_json::Value>> {
if fx.is_empty() {
return None;
}
fx.iter().map(|e| (e.id == "distort.transform").then(|| merged_params(&e.id, &e.params)).filter(|p| transform_move(p).is_some())).collect()
}
/// Type `n` in each place that Transform effects that only move ([`transform_moves`]) copy its
/// glyph outlines to: `n` moved there, the original's place first. `None` for other objects; for
/// type that is hidden, in perspective, has effects, transparency, an opacity mask or fills and
/// strokes of its own (beyond its characters'), has characters painted with a gradient or pattern,
/// underlined or struck through, has character strokes and is scaled or sheared, holds inline
/// graphics or has no glyphs; when the effects would stop copies of its outlines early
/// ([`transform_offsets`]); when the copies would pass [`MAX_TYPE_COPIES`] or
/// [`MAX_TYPE_COPY_BYTES`]; and when the copies overlap: what one copy paints (its
/// character strokes included) reaches into another's box.
fn moved_type(n: &Node, moves: &[serde_json::Value]) -> Option<Vec<Node>> {
let NodeKind::Text(t) = &n.kind else { return None };
let flat = |p: &Paint| matches!(p, Paint::None | Paint::Solid { .. });
let [a, b, c, d, _, _] = t.xf.as_coeffs();
let rigid = (a * a + b * b - 1.0).abs() < 1e-9 && (c * c + d * d - 1.0).abs() < 1e-9 && (a * c + b * d).abs() < 1e-9;
let stroked = t.runs.iter().any(|r| r.style.has_stroke());
if !n.visible
|| (stroked && !rigid)
|| n.projection().is_some()
|| !n.has_default_transparency()
|| !n.appearance.effects.is_empty()
|| n.appearance.items.iter().any(|i| paints(i) || !item_effects(i).is_empty())
|| t.runs.iter().any(|r| r.inline.is_some() || r.style.underline || r.style.strikethrough || !flat(&r.style.fill) || !flat(&r.style.stroke))
{
return None;
}
let (_, all) = glyph_outlines(t);
let outline = PathData::from_bezpath(&all).transformed(t.xf);
let ink = outline.bounds()?;
let offsets = transform_offsets(moves, &outline)?;
if offsets.len() > MAX_TYPE_COPIES {
return None;
}
let size = serde_json::to_vec(n).ok()?.len();
if size.saturating_mul(offsets.len()) > MAX_TYPE_COPY_BYTES {
return None;
}
let strokes = t.runs.iter().map(|r| r.style.appearance().outset()).fold(0.0, f64::max);
if !apart(&offsets, ink.inflate(strokes, strokes)) {
return None;
}
Some(
offsets
.into_iter()
.map(|o| {
let mut c = n.clone();
c.transform(Affine::translate(o), false);
c
})
.collect(),
)
}
/// Do the copies of box `b` moved by each of `offsets` stay clear of each other? Copies may touch.
/// False as well when an offset is 10^15 or more times the box's width or height.
fn apart(offsets: &[Vec2], b: Rect) -> bool {
let (w, h) = (b.width(), b.height());
if offsets.len() < 2 {
return true;
}
if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {
return false;
}
// Copies closer than `w` across and `h` down lie in the same or a neighboring cell.
let cell = |o: Vec2| {
let (x, y) = ((o.x / w).floor(), (o.y / h).floor());
(x.abs() < 1e15 && y.abs() < 1e15).then_some((x as i64, y as i64))
};
let mut cells: HashMap<(i64, i64), Vec2> = HashMap::new();
for &o in offsets {
let Some((x, y)) = cell(o) else { return false };
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(p) = cells.get(&(x + dx, y + dy))
&& (p.x - o.x).abs() < w
&& (p.y - o.y).abs() < h
{
return false;
}
}
}
cells.insert((x, y), o);
}
true
}
/// Type `n` whose own geometry effects only move it ([`transform_moves`]) as a group of the moved
/// type ([`moved_type`]). The group keeps `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects, and the type in it has none of them. `None` otherwise.
fn moved_type_group(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Text(_)) {
return None;
}
let moves = transform_moves(&geometry_effects(n))?;
let mut plain = n.clone();
plain.visible = true;
plain.opacity = 1.0;
plain.blend = Default::default();
plain.isolate = false;
plain.knockout_shape = false;
plain.extras_mut().mask = None;
plain.appearance.effects.clear();
let copies = moved_type(&plain, &moves)?;
let mut g = Node::group(n.id, copies.into_iter().map(Arc::new).collect());
g.name = n.name.clone();
g.visible = n.visible;
g.opacity = n.opacity;
g.blend = n.blend;
g.isolate = n.isolate;
// One element: its pieces never knock each other out.
g.knockout = Knockout::Off;
g.knockout_shape = n.knockout_shape;
g.extras_mut().mask = n.extras().mask.clone();
g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(g)
}
/// `n`, a group or layer without Pathfinder effects or fills and strokes that paint, whose geometry
/// effects only move its members ([`transform_moves`]), as plain art in which the type it holds
/// stays type: each type object moved, or replaced by a group of its copies ([`moved_type`]).
/// Type in a clipping path, other type that [`moved_type`] doesn't copy, and type inside live
/// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is
/// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of
/// them. `None` when no type stays type.
fn container_keeping_type(n: &Node) -> Option<Node> {
fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
if !matches!(c.kind, NodeKind::Text(_)) {
if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() {
outline_nested_text(Arc::make_mut(c), clipping);
} else if c.children().is_some() {
keep(Arc::make_mut(c), moves, clipping, kept);
}
continue;
}
// Type that isn't copied is outlined as `outline_nested_text` outlines it.
let copies = if clipping { None } else { moved_type(c, moves) };
let art = match copies {
Some(mut copies) => {
*kept = true;
match copies.pop() {
Some(only) if copies.is_empty() => Some(only),
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None => as_art(c, None),
};
if let Some(o) = art {
*c = Arc::new(o);
}
}
}
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || has_pathfinder(n) || has_own_paint(n) {
return None;
}
let fx = geometry_effects(n);
let moves = transform_moves(&fx)?;
let mut text = false;
n.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
if !text {
return None;
}
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook));
let mut kept = false;
keep(&mut m, &moves, false, &mut kept);
if !kept {
return None;
}
reshape_leaves(&mut m, &fx);
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
m.appearance.items.clear();
m.appearance.contents_index = None;
Some(m)
}
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
/// none of `n`'s transparency.
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
@@ -359,8 +567,9 @@ pub fn bake_document(doc: &Document) -> Option<Document> {
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
fn fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
@@ -408,4 +617,318 @@ mod tests {
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
}
/// Black 24 pt "Type" with its baseline at (20, 60).
fn type_node(id: u64) -> Node {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style))))
}
/// The origins of the type objects in `d`, in paint order.
fn type_origins(d: &Document) -> Vec<(f64, f64)> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let c = t.xf.as_coeffs();
out.push((c[4], c[5]));
}
});
out
}
/// A group of the type and a 10 pt square with `effects`.
fn type_group(effects: Vec<Effect>) -> Node {
let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]);
g.appearance.effects = effects;
g
}
#[test]
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
for (params, origins, square) in [
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
(json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)),
] {
let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]);
let d = bake_document(&doc_with(g.clone())).unwrap();
assert_eq!(type_origins(&d), origins, "{params}");
let baked = d.node(NodeId(52)).unwrap();
assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
// The square moves with the type, as one path holding its copies.
let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap();
assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}");
let mut ids = std::collections::HashSet::new();
baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id));
// The renderer and Expand Appearance still draw it as outlines.
let mut text = 0;
evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
assert_eq!(text, 0, "{params}");
}
// Stacked effects that only move: every copy of a copy.
let d = bake_document(&doc_with(type_group(vec![
fx("distort.transform", json!({"moveH": 100, "copies": 1})),
fx("distort.transform", json!({"moveV": 100, "copies": 1})),
])))
.unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]);
}
#[test]
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut hidden = type_node(55);
hidden.visible = false;
g.children_mut().unwrap().push(Arc::new(hidden));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
assert!(!d.node(NodeId(55)).unwrap().visible);
}
#[test]
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut shadowed = type_node(55);
shadowed.opacity = 0.5;
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
// Type that clips a group is outlined as the plain outlines it clips by.
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true });
g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]);
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
let o = d.node(NodeId(55)).unwrap();
assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}");
assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
// The outlines are copied with the type.
let b = o.geometric_bounds().unwrap();
assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}");
let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap();
assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}");
}
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
let mut moved = type_node(60);
moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300})));
let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]);
path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))));
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true });
g.children_mut().unwrap().push(Arc::new(clip));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
}
#[test]
fn type_with_its_own_transform_effect_copies_stays_type() {
let mut n = type_node(50);
n.opacity = 0.5;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
let d = bake_document(&doc_with(n)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]);
// One group keeps the object's transparency and its other effects; the copies are plain.
let g = d.node(NodeId(50)).unwrap();
assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5);
assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
for c in g.children().unwrap() {
assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}");
}
// Hidden type stays hidden.
let mut n = type_node(50);
n.visible = false;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))];
let d = bake_document(&doc_with(n)).unwrap();
assert_eq!(type_origins(&d).len(), 2);
assert!(!d.node(NodeId(50)).unwrap().visible);
}
#[test]
fn type_under_other_geometry_effects_is_still_outlined() {
for effects in [
vec![fx("distort.transform", json!({"moveH": 30, "random": true}))],
vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))],
vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))],
vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))],
vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))],
// Copies that would reach past the effect's limit, which stops them early.
vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))],
// Copies that overlap, or land on each other.
vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))],
vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))],
] {
let mut own = type_node(50);
own.appearance.effects = effects.clone();
for n in [type_group(effects.clone()), own] {
let d = bake_document(&doc_with(n)).unwrap();
assert!(type_origins(&d).is_empty(), "{effects:?}");
}
}
}
#[test]
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
// A 20 pt stroke on the characters reaches the copy below.
let mut stroked = type_node(50);
if let NodeKind::Text(t) = &mut stroked.kind {
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
t.runs[0].style.stroke_width = 20.0;
}
let mut g = type_group(moves);
g.children_mut().unwrap()[0] = Arc::new(stroked);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
#[test]
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
let mut translucent = type_node(50);
translucent.opacity = 0.5;
let mut underlined = type_node(50);
if let NodeKind::Text(t) = &mut underlined.kind {
t.runs[0].style.underline = true;
}
let mut shadowed = type_node(50);
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
let mut gradient = type_node(50);
if let NodeKind::Text(t) = &mut gradient.kind {
t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
}
let mut reshaped = type_node(50);
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
fill.effects.push(fx("path.offsetPath", json!({"offset": 2})));
reshaped.appearance.items.push(AppearanceItem::Fill(fill));
// A stroke of the type object's own (beyond its characters').
let mut stroked = type_node(50);
stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
// Character strokes scale with type scaled by its transform, where the outlines keep theirs.
let mut scaled = type_node(50);
if let NodeKind::Text(t) = &mut scaled.kind {
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
t.runs[0].style.stroke_width = 1.0;
t.xf *= Affine::scale(2.0);
}
let mut turned = scaled.clone();
if let NodeKind::Text(t) = &mut turned.kind {
t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5);
}
let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))];
for (t, kept) in [(turned, 2), (scaled, 0)] {
let mut g = type_group(far.clone());
g.children_mut().unwrap()[0] = Arc::new(t);
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept);
}
for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] {
let mut g = type_group(moves.clone());
g.children_mut().unwrap()[0] = Arc::new(t);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
}
#[test]
fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() {
let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))];
let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)));
let mut g = type_group(moves.clone());
g.appearance.items.push(fill.clone());
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
// A group with a fill of its own inside the moved group.
let mut inner = type_group(vec![]);
inner.id = NodeId(53);
inner.appearance.items.push(fill);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects = moves;
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
}
#[test]
fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() {
// The inner copy lands back on the original.
let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1})));
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
}
#[test]
fn type_whose_copies_hold_too_much_is_still_outlined() {
let spaced = |spaces: usize| {
let mut n = type_node(50);
if let NodeKind::Text(t) = &mut n.kind {
t.runs[0].text = format!("a{}", " ".repeat(spaces));
}
Arc::new(n)
};
// 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000
// bytes together, they are outlined.
let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))];
for (spaces, kept) in [(1000, 1001), (20_000, 0)] {
let mut g = type_group(copies.clone());
g.children_mut().unwrap()[0] = spaced(spaces);
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces");
}
// 1001 × 21 copies, past 10,000.
let mut g = type_group(vec![
fx("distort.transform", json!({"moveH": 100, "copies": 1000})),
fx("distort.transform", json!({"moveV": 100, "copies": 20})),
]);
g.children_mut().unwrap()[0] = spaced(0);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
#[test]
fn copies_are_apart_when_their_boxes_only_touch() {
let b = Rect::new(0.0, 0.0, 10.0, 5.0);
assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b));
assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b));
assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b));
assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b));
// Offsets too far for the cells' range count as overlapping.
assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0)));
}
#[test]
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1})));
let d = bake_document(&doc_with(outer)).unwrap();
assert!(type_origins(&d).is_empty());
// The glyphs take part in Add: the result reaches past the 10 pt square.
let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.width() > 30.0, "{b:?}");
}
#[test]
fn type_is_copied_only_when_the_effect_makes_every_copy() {
// A 10 pt line with `n` anchors.
let line = |n: usize| {
let pts: Vec<Point> = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect();
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)])
};
let right = json!({"moveH": 10, "copies": 1000});
let down = json!({"moveV": 10, "copies": 1});
// 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors.
assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001));
assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none());
// A second effect copies every copy.
assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002));
assert!(transform_offsets(&[right, down], &line(1000)).is_none());
// Copies stop before one reaches 1,000,000,000 pt from the origin.
let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100));
assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none());
// An effect that also scales.
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
}
}
+94 -16
View File
@@ -1,6 +1,7 @@
//! Distort & Transform effects.
use serde_json::Value;
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
use crate::util::*;
@@ -102,11 +103,59 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
PathData::new(subs)
}
/// The move and the number of copies of a Transform effect with params `p` that only moves what
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
/// or moves by a random share.
pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> {
let (m, copies) = transform_map(Rect::ZERO, p, 0);
let [a, b, c, d, x, y] = m.as_coeffs();
(!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
}
/// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the
/// original's place first, when each of them only moves it ([`transform_move`]): each effect moves
/// every place it is given, or with copies keeps them and adds each copy's. `None` when one of
/// them does more than move, or when [`transform`] stops its copies of `path` early.
pub(crate) fn transform_offsets(ps: &[Value], path: &PathData) -> Option<Vec<Vec2>> {
let mut offsets = vec![Vec2::ZERO];
let mut all = path.clone();
for p in ps {
let (v, copies) = transform_move(p)?;
let next = transform(&all, Rect::ZERO, p, 0);
if next.anchor_count() != all.anchor_count().saturating_mul(copies + 1) {
return None;
}
all = next;
if copies == 0 {
offsets.iter_mut().for_each(|o| *o += v);
continue;
}
let from = offsets.clone();
offsets.extend((1..=copies).flat_map(|k| from.iter().map(move |o| *o + v * k as f64)));
}
Some(offsets)
}
/// Largest scale (%) and move (pt) the Transform effect takes.
const MAX_SCALE: f64 = 100_000.0;
const MAX_MOVE: f64 = 1.0e7;
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
const MAX_REACH: f64 = 1.0e9;
/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
/// Most image and symbol instance nodes a Transform effect's copies make of an art, the originals
/// included: 1000 copies of a 2000-image art. Stacked Transform effects multiply their copies
/// (three of 1000 copies make a thousand million of one image), so the limit is on the result,
/// not on each effect's count.
pub(crate) const MAX_COPY_OBJECTS: usize = 2_000_000;
/// Does `r` stay within [`MAX_REACH`] of the origin? A Transform effect's copies stop before one
/// doesn't.
pub(crate) fn within_reach(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH)
}
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
@@ -114,6 +163,41 @@ const MAX_REACH: f64 = 1.0e9;
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
/// on every redraw while each object varies its own way.
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
}
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
let (m, copies) = transform_map(b, p, seed);
if copies == 0 {
return path.transformed(m);
}
// Each copy adds the path once more, and a Transform after this one copies the lot again:
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
let each = path.anchor_count();
let mut anchors = each;
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
anchors = anchors.saturating_add(each);
if anchors > max {
break;
}
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within_reach) {
break;
}
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out
}
/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds
/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map
/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies
/// otherwise (see [`transform_within`], and images, whose copies count nodes against
/// [`MAX_COPY_OBJECTS`]).
pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
let random = flag(p, "random", false);
// The share (0..1) of value `k` applied.
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
@@ -127,20 +211,7 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
* Affine::translate(-o);
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
if copies == 0 {
return path.transformed(m);
}
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within) {
break;
}
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out
(m, copies)
}
/// Tweak: random displacement of anchors and/or control points.
@@ -194,7 +265,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
map_nonlinear(path, pieces, f)
}
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
/// displaced along the normal.
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
@@ -207,8 +281,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let mut k = 0usize;
for i in 0..segs {
let c = sp.segment(i);
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
// near the anchors, and so does any uneven curve.
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
for j in 0..=ridges {
let t = j as f64 / (ridges + 1) as f64;
let s = j as f64 / (ridges + 1) as f64;
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
let nrm = if j == 0 {
// Average the normals of the segments meeting at the anchor.
let prev = if i > 0 {
+139 -6
View File
@@ -46,7 +46,9 @@ pub fn has_pathfinder(n: &Node) -> bool {
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
}
/// Converts objects that aren't paths (e.g. text) to an outline path node, if possible.
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
/// compound paths in that art, at every depth of its groups, take part in the Pathfinder operation
/// with the object's opacity and blend mode.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
@@ -61,8 +63,14 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
}
}
_ => {
if let Some(p) = hook.and_then(|h| h(n)) {
out.push(p);
// Every path of the outline art, at any depth, takes the object's opacity and blend
// mode.
if let Some(art) = hook.and_then(|h| h(n)) {
let first = out.len();
leaves(&art, None, out);
for l in out.iter_mut().skip(first) {
(l.opacity, l.blend) = (n.opacity, n.blend);
}
}
}
}
@@ -281,9 +289,9 @@ pub fn evaluate_container(n: &Node) -> Option<Node> {
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
Arc::new(Node::path(
@@ -319,6 +327,71 @@ mod tests {
assert_eq!(div.len(), 3);
}
/// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either
/// side of x = 100.
fn type_h() -> Node {
let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() };
Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style))))
}
/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it.
fn square_and(front: Option<Node>, effect: &str) -> Node {
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red);
let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect());
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
g
}
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
fn results(g: &Node) -> Vec<Arc<Node>> {
pathfinder_children(g, crate::text_outliner()).unwrap()
}
#[test]
fn type_changes_the_result_of_every_pathfinder_effect() {
let glyphs = crate::outline_text(&type_h()).unwrap();
let b = glyphs.geometric_bounds().unwrap();
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
// `measure` gives the number of results and their total area.
let measure = |front: Option<Node>, effect: &str| {
let r = results(&square_and(front, effect));
(r.len(), r.iter().map(|n| area(n)).sum::<f64>())
};
let unchanged: Vec<_> = PATHFINDER_EFFECTS
.iter()
.map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id)))
.filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0)
.collect();
assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}");
// The shape modes take the glyph's whole area: the part inside the square and the part outside.
let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum();
let with_type = |id: &str| measure(Some(type_h()), id).1;
assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}");
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
// Outline art nested one group deeper takes part as well.
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
}
#[test]
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
let mut t = type_h();
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal);
let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply);
let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect();
assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}");
// In the shape modes a group with the type in front is one shape, styled like the type.
let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green);
let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]);
let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect();
assert_eq!(added, [glyph]);
}
#[test]
fn a_random_transform_on_a_group_moves_its_members_together() {
let mut g = group_with("distort.transform");
@@ -330,6 +403,40 @@ mod tests {
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
}
/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`,
/// in a group with a square and Offset Path 0, which leaves every path as it is.
fn type_in_group(style: impl FnOnce(&mut Node)) -> Node {
let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts);
let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text)));
style(&mut t);
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
let art = evaluate_container(&g).unwrap();
(*art.children().unwrap()[1]).clone()
}
#[test]
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
use vectorcraft_color::BlendMode;
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply));
// Its opacity mask, its own shadow, its knockout shape option and its visibility stay.
let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
let mask = vectorcraft_doc::OpacityMask::new(mask_art, true);
assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask));
let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true };
assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]);
assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape);
assert!(!type_in_group(|t| t.visible = false).visible);
// Its own Transform moves its outlines 30 pt before the group's effect reshapes them.
let x0 = |n: Node| n.geometric_bounds().unwrap().x0;
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true };
let moved = x0(type_in_group(|t| t.appearance.effects.push(transform)));
assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}");
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
@@ -337,4 +444,30 @@ mod tests {
assert!(pathfinder_children(&g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
#[test]
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
// The type's characters fit the gradient to its layout; the square has it placed across itself.
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text))));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0)));
let start = |n: &Node| match n.appearance.fill_paint() {
Paint::Gradient(g) => g.geom.unwrap().start.x,
p => panic!("a gradient: {p:?}"),
};
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
let mut g = Node::group(NodeId(10), vec![square, t]);
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let art = evaluate_container(&g).unwrap();
let members = art.children().unwrap();
let after = start(&members[1].children().unwrap()[0]);
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
}
}
+96 -22
View File
@@ -134,7 +134,7 @@ fn lengths_of(id: &str) -> Lengths {
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
"stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]),
"stylize.dropShadow" => always(&["x", "y", "blur"]),
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
@@ -200,29 +200,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
"Extrude & Bevel…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
INFLATE,
"Inflate…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
ROTATE,
"Rotate…",
&["Effect", "3D and Materials"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
"convertToShape.rectangle",
@@ -249,7 +249,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.freeDistort",
"Free Distort…",
DT,
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
),
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
@@ -301,15 +301,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"stylize.scribble",
"Scribble…",
STYLIZE,
"{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}),
"{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}),
),
r(
"stylize.dropShadow",
"Drop Shadow…",
STYLIZE,
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
),
r(
"stylize.innerGlow",
@@ -345,8 +345,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"blur.smart",
"Smart Blur…",
BLUR,
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's; the white and the black keep each pixel's coverage, and the radius changes only the blur",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
),
r(
"brushStrokes.accentedEdges",
@@ -415,7 +415,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.glass",
"Glass…",
DISTORT,
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\"|\"loaded\" (\"frosted\"; \"loaded\" draws the image in textureImage and draws Frosted when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray",
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
),
r(
@@ -642,21 +642,83 @@ pub fn default_params(id: &str) -> Option<Value> {
}
}
/// `params` layered over the defaults of `id` (unknown keys are kept).
/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see
/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and
/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`.
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
Some(Value::Object(m)) => m.clone(),
Some(_) => Map::new(),
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
};
if let Value::Object(p) = params {
for (k, v) in p {
out.insert(k.clone(), v.clone());
}
// Upgraded before the defaults are under it: a default angle is no angle given.
if let Value::Object(p) = upgrade_params(id, params) {
out.extend(p);
}
Value::Object(out)
}
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
let mut m = m.clone();
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
Value::Object(m)
}
other => other.clone(),
}
}
fn upgrade_light(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation"));
if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") {
return;
}
let degrees = |v: Option<Value>, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default);
let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0));
m.insert("lightRotation".into(), json!(rotation));
m.insert("lightHeight".into(), json!(height));
m.insert("lightBehind".into(), json!(behind));
}
fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let Some(strength) = m.remove("perspective") else { return };
if !m.contains_key("perspectiveAngle") {
let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0);
m.insert("perspectiveAngle".into(), json!(angle));
}
}
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
if id != "stylize.scribble" {
return;
}
if let Some(variation) = m.remove("variation") {
for key in ["overlapVariation", "spacingVariation"] {
m.entry(key).or_insert_with(|| variation.clone());
}
}
if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) {
let wrapped = angle.rem_euclid(360.0);
m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped }));
}
}
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
@@ -706,6 +768,18 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
effects.iter().any(|e| e.visible && is_geometry(&e.id))
}
/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws
/// what the plug-in installed under its id draws ([`plugin_revision`]).
pub fn has_plugin_effect(n: &Node) -> bool {
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GeomContext<'a> {
+79 -5
View File
@@ -1,8 +1,8 @@
//! Blur › Radial Blur and Smart Blur.
//! Blur › Radial Blur and Smart Blur (Normal, Edge Only and Overlay Edge).
use vectorcraft_geom::{Affine, Point, Vec2};
use super::{PixelSpace, Px16, box_blur, to8, to16};
use super::{PixelSpace, Px16, box_blur, pick, to8, to16};
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
pub(super) fn zoom_extent(amount: f64) -> f64 {
@@ -69,6 +69,39 @@ pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
const MAX_SMART: f64 = 4096.0;
/// Smart Blur's modes, in its Mode menu's order: (label, parameter value).
pub const SMART_BLUR_MODES: [(&str, &str); 3] = [("Normal", "normal"), ("Edge Only", "edgeOnly"), ("Overlay Edge", "overlayEdge")];
/// What Smart Blur draws ([`SMART_BLUR_MODES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SmartBlurMode {
/// The object blurred.
Normal,
/// The edges in white on black.
EdgeOnly,
/// The edges in white over the object blurred.
OverlayEdge,
}
impl SmartBlurMode {
const ALL: [SmartBlurMode; 3] = [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge];
/// The mode of `value` (a [`SMART_BLUR_MODES`] value, any case); Normal otherwise.
pub fn parse(value: &str) -> Self {
pick(&SMART_BLUR_MODES, &Self::ALL, value, SmartBlurMode::Normal)
}
}
/// Smart Blur's `threshold` (8-bit levels) in the levels of [`Px16`].
fn levels(threshold: f64) -> i32 {
(threshold.clamp(0.0, 255.0) * 257.0) as i32
}
/// Do all of `a`'s channels lie within `t` levels of `b`'s?
fn close(a: &Px16, b: &Px16, t: i32) -> bool {
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
}
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
@@ -102,7 +135,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
} else {
&src
};
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
let t = levels(threshold);
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
let mut acc = c.map(u64::from);
@@ -115,8 +148,7 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
let i = sy as usize * w + sx as usize;
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
// The neighbour itself and its surroundings both close enough: edges stay sharp.
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
if close(own) && close(s) {
if close(own, c, t) && close(s, c, t) {
for (a, v) in acc.iter_mut().zip(s) {
*a += *v as u64;
}
@@ -127,3 +159,45 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
}
}
}
/// Smart Blur's Edge Only (`overlay` false) and Overlay Edge (`overlay` true) modes: the pixels on
/// an edge drawn white, over black or over the Normal mode's result ([`smart`]). A pixel is on an
/// edge when one of its four nearest neighbors (transparent beyond the raster) has a premultiplied
/// channel more than `threshold` levels from its own and its channels add up to at least the
/// neighbor's, so a step between two colors gives a line one pixel wide on the side whose channels
/// add up to more, and two pixels wide when the sums are equal. The white and the black keep the
/// pixel's coverage.
pub(super) fn smart_edges(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32, overlay: bool) {
let src = to16(px);
let t = levels(threshold);
let sum = |p: &Px16| p.iter().map(|v| u32::from(*v)).sum::<u32>();
let at = |x: usize, y: usize, dx: isize, dy: isize| -> Px16 {
let (Some(x), Some(y)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { return [0; 4] };
if x >= w || y >= h {
return [0; 4];
}
src.get(y * w + x).copied().unwrap_or([0; 4])
};
let edge: Vec<bool> = src
.iter()
.enumerate()
.map(|(i, c)| {
let (x, y) = (i % w.max(1), i / w.max(1));
[(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
let n = at(x, y, *dx, *dy);
!close(&n, c, t) && sum(c) >= sum(&n)
})
})
.collect();
if overlay {
smart(px, w, h, radius, threshold, samples);
}
for ((p, c), on_edge) in px.iter_mut().zip(&src).zip(&edge) {
let a = to8(c[3]);
if *on_edge {
*p = [a; 4];
} else if !overlay {
*p = [0, 0, 0, a];
}
}
}
+22 -3
View File
@@ -7,8 +7,11 @@
//! white grain. Every surface lies in document space around the object's centre, as the Texture
//! filters' do.
use std::sync::Arc;
use vectorcraft_geom::{Point, Vec2};
use super::loaded::{LOADED, LoadedTexture, texture};
use super::pixelate::{Place, value_noise};
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
@@ -20,13 +23,19 @@ const SALT_RIPPLE: u64 = 136;
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
/// Glass's Texture menu once a texture is loaded: [`GLASS_TEXTURES`], then the loaded one.
pub const GLASS_TEXTURES_LOADED: [(&str, &str); 5] =
[("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens"), ("Loaded Texture", LOADED)];
/// A Glass surface.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GlassTexture {
Blocks,
Canvas,
Frosted,
TinyLens,
/// An image the user loaded ([`LoadedTexture`]).
Loaded(Arc<LoadedTexture>),
}
impl GlassTexture {
@@ -37,13 +46,23 @@ impl GlassTexture {
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
}
/// The surface of `value` with `stored` the effect's `textureImage`: the loaded texture
/// for [`LOADED`] (any case) when `stored` holds one, else as [`GlassTexture::parse`].
pub fn parse_with(value: &str, stored: Option<&str>) -> Self {
match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) {
Some(t) => GlassTexture::Loaded(t),
None => Self::parse(value),
}
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
fn height(self, p: Point) -> f64 {
fn height(&self, p: Point) -> f64 {
match self {
GlassTexture::Blocks => blocks(p),
GlassTexture::Canvas => Texture::Canvas.height(p),
GlassTexture::Frosted => frosted(p),
GlassTexture::TinyLens => tiny_lens(p),
GlassTexture::Loaded(t) => t.height(p),
}
}
}
@@ -103,7 +122,7 @@ pub(super) fn glass(
space: &PixelSpace,
distortion: f64,
smoothness: f64,
texture: GlassTexture,
texture: &GlassTexture,
scaling: f64,
invert: bool,
) {
+168
View File
@@ -0,0 +1,168 @@
//! Load Texture (Effect › Distort › Glass): a surface made from an image the user loads.
//!
//! The effect keeps the image's gray levels in its parameters ([`TEXTURE_IMAGE`]: a grayscale PNG,
//! base64), so the document saves the texture with the effect, and copies of the effect carry it.
//! Each pixel's gray is the surface's height there: white stands [`RELIEF`] above black. The image
//! lies one pixel per point at 100 %, centered on the object's center and repeated in both
//! directions.
use std::io::Cursor;
use std::sync::{Arc, Mutex, PoisonError};
use vectorcraft_geom::Point;
use super::texture::luma;
/// The effect parameter that holds a loaded texture: its gray levels as a grayscale PNG, base64.
pub const TEXTURE_IMAGE: &str = "textureImage";
/// The Texture menu's value for the loaded texture.
pub const LOADED: &str = "loaded";
/// How far (points) a loaded texture's white stands above its black: a black pixel beside a white
/// one gives a slope of about 1, the most the built-in surfaces have.
const RELIEF: f64 = 1.0;
/// How many decoded textures [`texture`] keeps.
const KEEP: usize = 4;
/// A loaded texture: `width` × `height` gray levels, row by row from the top.
#[derive(Debug, PartialEq, Eq)]
pub struct LoadedTexture {
width: u32,
height: u32,
gray: Vec<u8>,
}
impl LoadedTexture {
/// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's
/// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray).
/// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels.
pub fn from_rgba(width: u32, height: u32, rgba: &[u8]) -> Option<Self> {
let n = (width as usize).checked_mul(height as usize).filter(|n| *n > 0)?;
let (px, rest) = rgba.as_chunks::<4>();
if px.len() != n || !rest.is_empty() {
return None;
}
let gray = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let l = luma([p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0));
((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8
})
.collect();
Some(Self { width, height, gray })
}
/// The size in pixels.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Its [`TEXTURE_IMAGE`] value: the gray levels as a grayscale PNG, base64; `None` when the
/// PNG can't be written.
pub fn to_stored(&self) -> Option<String> {
let img = image::GrayImage::from_raw(self.width, self.height, self.gray.clone())?;
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(base64_encode(&png))
}
/// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`];
/// `None` when `png` isn't a PNG or holds no pixels.
fn from_png(png: &[u8]) -> Option<Self> {
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
reader.limits(limits);
let img = reader.decode().ok()?.to_luma8();
let (width, height) = img.dimensions();
(width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() })
}
/// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray
/// levels around it interpolated, 0 for black and [`RELIEF`] for white.
pub(super) fn height(&self, p: Point) -> f64 {
let (w, h) = (f64::from(self.width), f64::from(self.height));
// Pixel centers lie at whole coordinates, and the image's center lies at the origin.
let (x, y) = (p.x + 0.5 * w - 0.5, p.y + 0.5 * h - 0.5);
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let at = |i: f64, j: f64| {
let (i, j) = (i.rem_euclid(w) as usize, j.rem_euclid(h) as usize);
self.gray.get(j * self.width as usize + i).map_or(0.5, |g| f64::from(*g) / 255.0)
};
let top = at(x0, y0) + (at(x0 + 1.0, y0) - at(x0, y0)) * fx;
let bottom = at(x0, y0 + 1.0) + (at(x0 + 1.0, y0 + 1.0) - at(x0, y0 + 1.0)) * fx;
RELIEF * (top + (bottom - top) * fy)
}
}
/// The textures [`texture`] decoded last, with their stored form, the newest last.
static DECODED: Mutex<Vec<(String, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
/// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last
/// [`KEEP`] textures decoded are kept for the next call.
pub fn texture(stored: &str) -> Option<Arc<LoadedTexture>> {
let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = decoded.iter().position(|(s, _)| s == stored) {
let entry = decoded.remove(i);
let t = entry.1.clone();
decoded.push(entry);
return Some(t);
}
let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?);
if decoded.len() >= KEEP {
decoded.remove(0);
}
decoded.push((stored.to_string(), t.clone()));
Some(t)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 (RFC 4648, with padding), written as `vectorcraft_format::base64_encode`
/// writes it.
fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
/// Standard base64 (RFC 4648, with padding; whitespace skipped), read as
/// `vectorcraft_format::base64_decode` reads it; `None` when it isn't base64.
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32);
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !clean.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
for c in clean.chunks(4) {
let mut n = 0u32;
let mut pad = 0;
for &b in c {
n <<= 6;
if b == b'=' {
pad += 1;
} else {
n |= val(b)?;
}
}
out.push((n >> 16) as u8);
if pad < 2 {
out.push((n >> 8) as u8);
}
if pad < 1 {
out.push(n as u8);
}
}
Some(out)
}
+23 -11
View File
@@ -17,6 +17,7 @@ mod blur;
mod brushstrokes;
mod distort;
mod edges;
mod loaded;
mod pixelate;
mod sharpen;
mod texture;
@@ -27,8 +28,10 @@ use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{flag, num, text};
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
pub use distort::{GLASS_TEXTURES, GlassTexture};
pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture};
pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture};
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
@@ -66,7 +69,7 @@ pub const PIXEL_EFFECTS: [&str; 28] = [
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
/// are in document units.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
@@ -77,7 +80,10 @@ pub enum PixelFx {
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
/// `samples` × `samples` neighbours at most.
SmartBlur { radius: f64, threshold: f64, samples: u32 },
/// The Edge Only `mode` draws the edges where neighboring pixels differ by more than
/// `threshold` levels in white on black, and Overlay Edge draws them in white over the Normal
/// result.
SmartBlur { radius: f64, threshold: f64, samples: u32, mode: SmartBlurMode },
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
AccentedEdges { width: f64, brightness: f64, smoothness: f64 },
@@ -107,9 +113,9 @@ pub enum PixelFx {
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
/// `invert`.
/// Distort › Glass: the object seen through a `texture` surface (a built-in one, or an image
/// the user loaded) scaled by `scaling` (0.5..2), bent by `distortion` (0..20), smoothed by
/// `smoothness` (1..15), its heights turned over when `invert`.
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
/// `magnitude` (0..20) says.
@@ -211,6 +217,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
mode: SmartBlurMode::parse(text(p, "blurMode", "normal")),
},
"brushStrokes.accentedEdges" => PixelFx::AccentedEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
@@ -258,7 +265,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"distort.glass" => PixelFx::Glass {
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
texture: GlassTexture::parse(text(p, "texture", "frosted")),
texture: GlassTexture::parse_with(text(p, "texture", "frosted"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
invert: flag(p, "invert", false),
},
@@ -412,7 +419,12 @@ impl PixelFx {
match *self {
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
blur::smart(px, w, h, to_px(radius), threshold, samples)
}
PixelFx::SmartBlur { radius, threshold, samples, mode } => {
blur::smart_edges(px, w, h, to_px(radius), threshold, samples, mode == SmartBlurMode::OverlayEdge)
}
PixelFx::AccentedEdges { width, brightness, smoothness } => brushstrokes::accented_edges(px, w, h, space, width, brightness, smoothness),
PixelFx::AngledStrokes { balance, length, sharpness } => brushstrokes::angled_strokes(px, w, h, space, balance, length, sharpness),
PixelFx::Crosshatch { length, sharpness, strength } => brushstrokes::crosshatch(px, w, h, space, length, sharpness, strength),
@@ -422,7 +434,7 @@ impl PixelFx {
PixelFx::SprayedStrokes { length, radius, direction } => brushstrokes::sprayed_strokes(px, w, h, space, length, radius, direction),
PixelFx::SumiE { width, pressure, contrast } => brushstrokes::sumi_e(px, w, h, space, width, pressure, contrast),
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
PixelFx::Glass { distortion, smoothness, ref texture, scaling, invert } => {
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
@@ -448,8 +460,8 @@ impl PixelFx {
/// 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)
fn pick<T: Clone>(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.clone())
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
+176 -4
View File
@@ -36,7 +36,7 @@ fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => *p,
Some(RasterFx::Pixel(p)) => p.clone(),
other => panic!("{id}: {other:?}"),
}
}
@@ -465,11 +465,22 @@ fn params_take_defaults_and_stay_in_range() {
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
);
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::Normal });
assert_eq!(
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low", "blurMode": "EDGEONLY"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5, mode: SmartBlurMode::EdgeOnly }
);
assert_eq!(
fx("blur.smart", json!({"blurMode": "overlayEdge"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7, mode: SmartBlurMode::OverlayEdge }
);
assert_eq!(fx("blur.smart", json!({"blurMode": "overlay"})), fx("blur.smart", json!({})));
// Every Mode menu value is documented in the catalogue and parses to a mode of its own.
let doc = crate::effect_info("blur.smart").unwrap().params;
for (i, (_, v)) in SMART_BLUR_MODES.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{v}");
assert_eq!(SmartBlurMode::parse(v), [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge][i], "{v}");
}
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
assert_eq!(
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
@@ -616,6 +627,105 @@ fn smart_blur_smooths_noise_and_keeps_edges() {
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
}
/// FNV-1a of `bytes`.
fn digest(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
#[test]
fn smart_blur_normal_mode_keeps_its_pixels() {
let (w, h) = (48, 40);
let src = image(w, h, |x, y| {
let a = if x > 4 && y < 36 { 255 } else { 128 };
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
});
let mut sums = Vec::new();
for p in [json!({}), json!({"radius": 2, "threshold": 10, "quality": "high"}), json!({"radius": 12, "threshold": 40, "quality": "low"})] {
let mut d = src.clone();
fx("blur.smart", p.clone()).apply(&mut d, w, h, &space(w, h));
let mut normal = src.clone();
let mut p = p;
p["blurMode"] = json!("normal");
fx("blur.smart", p).apply(&mut normal, w, h, &space(w, h));
assert_eq!(d, normal);
sums.push(digest(&d));
}
// The pixels Smart Blur gave before it had a mode.
assert_eq!(sums, [11495339692959753241, 14281410954765351056, 5111933201350526354]);
}
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
/// beyond it) and column 6, the step's lighter side.
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
let (w, h) = (12, 8);
let src = image(w, h, |x, y| match x {
0..3 => grey(if (x + y) % 2 == 0 { 56 } else { 64 }),
3..6 => grey(if (x + y) % 2 == 0 { 66 } else { 74 }),
_ => grey(200),
});
(w, h, src, move |x, y| x == 0 || y == 0 || x == w - 1 || y == h - 1 || x == 6)
}
#[test]
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
let (w, h, src, edge) = smart_blur_steps();
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
assert_eq!(d, want);
// The radius changes only the blur.
let mut wide = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
assert_eq!(wide, want);
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
let mut low = src.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
for y in 1..h - 1 {
for x in 1..6 {
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
assert_eq!(at(&low, w, x, y)[0] == 255, lighter, "({x}, {y})");
}
}
// White and black keep each pixel's coverage, and transparency stays.
let (w, h) = (8, 8);
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
let mut s = shape.clone();
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
(true, _) => [128; 4],
(_, true) => [0, 0, 0, 128],
_ => [0; 4],
});
assert_eq!(s, want);
}
#[test]
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
let (w, h, src, edge) = smart_blur_steps();
let mut normal = src.clone();
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
assert_ne!(normal, src, "the checkerboard is blurred");
let mut d = src.clone();
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
for y in 0..h {
for x in 0..w {
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
assert_eq!(at(&d, w, x, y), want, "({x}, {y})");
}
}
// Parameters past their ranges, at any resolution, still finish.
for mode in ["edgeOnly", "overlayEdge"] {
for s in [PixelSpace { px: 1e-9, ..space(w, h) }, PixelSpace { px: 1e9, ..space(w, h) }] {
let mut e = src.clone();
fx("blur.smart", json!({"blurMode": mode, "radius": 1e308, "threshold": 1e308, "quality": "high"})).apply(&mut e, w, h, &s);
}
}
}
#[test]
fn unsharp_mask_overshoots_at_edges() {
let (w, h) = (40, 10);
@@ -1309,6 +1419,68 @@ fn glass_bends_the_image_by_its_texture() {
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
}
/// The stored form of a `w` × `h` texture whose straight RGBA pixels `f` gives.
fn stored(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> String {
LoadedTexture::from_rgba(w as u32, h as u32, &image(w, h, f)).and_then(|t| t.to_stored()).unwrap()
}
#[test]
fn a_loaded_texture_is_its_gray_over_middle_gray() {
let t = stored(3, 1, |x, _| [[255, 255, 255, 255], [0, 0, 0, 0], [255, 0, 0, 255]][x]);
let loaded = texture(&t).unwrap();
assert_eq!(loaded.size(), (3, 1));
assert_eq!(texture(&t), Some(loaded.clone()), "read again from the cache");
// A white pixel is 1 high, a transparent one is middle gray and a red one is its luma. Heights
// between pixel centers are interpolated, and the image repeats: x = -1 is the first pixel's
// center, and x = 2 is the same pixel again.
let h = |x: f64| loaded.height(Point::new(x, 0.0));
assert_eq!(h(-1.0), 1.0);
assert_eq!(h(0.0), 128.0 / 255.0);
assert_eq!(h(1.0), 76.0 / 255.0);
assert_eq!(h(2.0), 1.0);
assert_eq!(h(-0.5), 0.5 * (1.0 + 128.0 / 255.0));
assert!(LoadedTexture::from_rgba(0, 4, &[]).is_none() && LoadedTexture::from_rgba(2, 2, &[0; 12]).is_none());
assert!(texture("not base64!").is_none() && texture("aGVsbG8=").is_none(), "base64 of a PNG only");
}
#[test]
fn glass_bends_the_image_by_a_loaded_texture() {
let (w, h) = (48, 40);
let src = stripes(w, h);
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
d
};
// Rings around the texture's center, 9 × 7 pixels, repeated.
let rings = stored(9, 7, |x, y| grey((((x as f64 - 4.0).hypot(y as f64 - 3.0) * 60.0) as u32 % 256) as u8));
let loaded = json!({"texture": "loaded", "textureImage": rings, "distortion": 12});
let bent = run(loaded.clone());
assert_ne!(bent, src);
for (_, texture) in GLASS_TEXTURES {
assert_ne!(bent, run(json!({"texture": texture, "distortion": 12})), "differs from {texture}");
}
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "invert": true})), bent, "inverted");
assert_ne!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 12, "scaling": 200})), bent, "scaled");
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
let p = at(&bent, w, x, y);
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "at ({x}, {y}): {p:?}");
}
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "distortion": 0})), src, "no distortion, no change");
// A flat texture bends nothing, whatever its gray; so does a transparent one (middle gray).
for flat in [grey(0), grey(200), [0, 0, 0, 0]] {
assert_eq!(run(json!({"texture": "loaded", "textureImage": stored(5, 5, |_, _| flat), "distortion": 20})), src, "{flat:?}");
}
// The image is kept but not used with a built-in texture; without one, "loaded" is Frosted.
assert_eq!(run(json!({"texture": "blocks", "textureImage": rings, "distortion": 12})), run(json!({"texture": "blocks", "distortion": 12})));
assert_eq!(fx("distort.glass", json!({"texture": "loaded"})), fx("distort.glass", json!({})));
assert_eq!(fx("distort.glass", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("distort.glass", json!({})));
assert_eq!(
fx("distort.glass", loaded),
PixelFx::Glass { distortion: 12.0, smoothness: 3.0, texture: GlassTexture::Loaded(texture(&rings).unwrap()), scaling: 1.0, invert: false }
);
}
#[test]
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
let (w, h) = (64, 32);
+6
View File
@@ -21,6 +21,9 @@ pub enum RasterFx {
dy: f64,
blur: f64,
color: Color,
/// Darkness (0..1): the shadow is the object's own colors with this share of black added
/// instead of `color`; `None` when it is `color`.
darkness: Option<f32>,
},
OuterGlow {
mode: BlendMode,
@@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
blur,
color: color_param(&p, Color::BLACK),
darkness: text(&p, "shade", "color")
.eq_ignore_ascii_case("darkness")
.then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32),
},
"stylize.outerGlow" => RasterFx::OuterGlow {
mode: blend_mode(text(&p, "mode", "screen")),
+223 -21
View File
@@ -10,7 +10,7 @@
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
@@ -30,9 +30,17 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
/// path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let (runs, all, _) = laid_out(t);
(runs, all)
}
/// [`glyph_outlines`] and the bounds of `t`'s layout in text space.
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect) {
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
@@ -41,25 +49,49 @@ pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::Bez
}
all.extend(g.outline.iter());
}
(runs, all)
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) {
r.extend(bar.iter());
}
all.extend(bar.iter());
}
(runs, all, layout.bounds)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
/// the characters under them and the others over them, as the renderer paints them.
/// the characters under them and the others over them, as the renderer paints them. Each run's
/// path is placed by the type's transform, which also scales its stroke weights and dashes and
/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in
/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds
/// in the document. The group is marked as type's outlines
/// ([`vectorcraft_doc::NodeExtras::type_outlines`]).
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let (runs, all) = glyph_outlines(t);
let (runs, all, bounds) = laid_out(t);
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() };
let mut children: Vec<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
.map(|(bp, run)| {
let mut ap = run.style.appearance();
ap.pin_gradients(bounds);
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap);
piece.transform(t.xf, scaling);
Arc::new(piece)
})
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let (below, above) = n.appearance.split_contents();
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
let tb = t.xf.transform_rect_bbox(bounds);
let glyphs = |items: &[AppearanceItem]| {
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
ap.pin_gradients(tb);
Arc::new(Node::path(n.id, path(&all), ap))
};
if !below.is_empty() {
children.insert(0, glyphs(below));
}
@@ -67,7 +99,9 @@ pub fn outline_text(n: &Node) -> Option<Node> {
children.push(glyphs(above));
}
}
Some(Node::group(n.id, children))
let mut g = Node::group(n.id, children);
g.extras_mut().type_outlines = true;
Some(g)
}
/// `n` as plain art that geometry effects can reshape, without its transparency and object
@@ -86,7 +120,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
outline_nested_text(&mut art);
outline_nested_text(&mut art, false);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -97,22 +131,33 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(art)
}
/// Replace type anywhere inside `n` by its outlines.
fn outline_nested_text(n: &mut Node) {
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if let Some(o) = outline_text(c) {
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) => as_art(c, None),
_ => None,
};
if let Some(o) = art {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c));
outline_nested_text(Arc::make_mut(c), clipping);
}
}
}
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied:
/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped
/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and
/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a
/// perspective, or has no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
return None;
@@ -154,11 +199,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
out.visible = n.visible;
out.opacity = n.opacity;
out.blend = n.blend;
out.isolate = n.isolate;
// One element: its pieces never knock each other out.
out.knockout = vectorcraft_doc::Knockout::Off;
out.knockout_shape = n.knockout_shape;
out.extras_mut().mask = n.extras().mask.clone();
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(out)
@@ -197,9 +244,15 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
}
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box and the art's seed (a random Transform moves the pieces together).
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let seed = art.id.0;
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
// pieces, from the outlines' bounds before the effects to their bounds after them.
let mut outlines = vec![];
type_outline_bounds(art, &mut outlines);
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
@@ -208,7 +261,156 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
set_geometry(leaf, out, rule);
});
if e.id == "distort.transform" {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
transform_placed(art, m, copies);
}
}
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
rebase_type_outlines(art, &mut outlines.into_iter());
}
}
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
if n.extras().type_outlines {
out.push(n.geometric_bounds());
} else if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
for c in children {
type_outline_bounds(c, out);
}
}
}
/// Move and scale the placed gradients of each group of type's outlines under `n` from its bounds
/// in `before` ([`type_outline_bounds`] before its paths changed) to its bounds now.
fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
if !n.extras().type_outlines {
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
rebase_type_outlines(Arc::make_mut(c), before);
}
}
} else if let Some(Some(from)) = before.next()
&& let Some(to) = n.geometric_bounds()
&& from != to
{
for_each_leaf(n, &mut |leaf| {
if leaf.appearance.has_placed_gradient() {
follow_boxes(&mut leaf.appearance, from, to);
}
});
}
}
/// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box
/// onto the other (strokes: their stroke-inflated boxes). The start and end go where
/// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are
/// mapped with them.
fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
for i in &mut ap.items {
match i {
AppearanceItem::Fill(f) => {
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut f.paint {
g.transform(box_map(from, to));
}
}
AppearanceItem::Stroke(s) => {
let m = box_map(s.paint_bounds(from), s.paint_bounds(to));
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut s.paint {
g.transform(m);
}
}
}
}
}
/// The map that takes box `from` onto box `to` along each axis (along an axis `from` has no extent
/// on, a move from its center to `to`'s).
fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
let axis = |a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 {
let s = (b1 - b0) / (a1 - a0);
(s, b0 - a0 * s)
} else {
(1.0, (b0 + b1 - a0 - a1) / 2.0)
}
};
let (sx, tx) = axis(from.x0, from.x1, to.x0, to.x1);
let (sy, ty) = axis(from.y0, from.y1, to.y0, to.y1);
vectorcraft_geom::Affine::new([sx, 0.0, 0.0, sy, tx, ty])
}
/// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as
/// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more
/// follow, each mapped once more than the last. Without copies the object itself takes the map,
/// as a path does.
fn transform_placed(n: &mut Node, m: Affine, copies: usize) {
transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS)
}
/// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art,
/// the originals included: each copy adds the object once more, and a Transform after this one
/// copies the lot again, so copies stop where the result would pass `max` (#2042).
pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
let mut room = max.saturating_sub(placed_count(n));
copy_placed(n, m, copies, &mut room);
}
/// An object that a Transform effect places through its transform rather than reshapes: an image
/// (pixel space to document) or a symbol instance (the symbol's art to document).
fn is_placed(n: &Node) -> bool {
matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. })
}
/// The walk of [`transform_placed`]: the map and its copies go to each image and symbol instance in
/// document order, each copy taking one from `room` (what the art can still grow by), until the
/// budget is full. As with a path, the copies stop before one reaches past
/// [`crate::distort::within_reach`].
fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
if !is_placed(c) {
copy_placed(Arc::make_mut(c), m, copies, room);
continue;
}
let src = (**c).clone();
let placed = |xf: Affine| {
let mut one = src.clone();
match &mut one.kind {
NodeKind::Image(im) => im.xf = xf * im.xf,
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
_ => {}
}
one
};
if copies == 0 {
*c = Arc::new(placed(m));
continue;
}
// The copies become nodes of their own, under a group with the object's id, name and place
// in the art (as the runs an outlined text gets).
let mut placements: Vec<Arc<Node>> = vec![Arc::new(placed(Affine::IDENTITY))];
let mut xf = Affine::IDENTITY;
for _ in 0..copies.min(*room) {
xf *= m;
let copy = placed(xf);
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
}
*room -= placements.len() - 1;
let mut g = Node::group(src.id, placements);
g.name = src.name.clone();
*c = Arc::new(g);
}
}
/// How many images and symbol instances `n` holds below itself.
fn placed_count(n: &Node) -> usize {
n.children().into_iter().flatten().map(|c| usize::from(is_placed(c)) + placed_count(c)).sum()
}
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
@@ -216,7 +418,7 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
pub(crate) fn outlined_members(n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
outline_nested_text(&mut art);
outline_nested_text(&mut art, false);
art
}
+113 -8
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{PathData, Rect};
use vectorcraft_three_d::{Edge, Revolve};
@@ -29,13 +29,17 @@ pub fn revolve_options(p: &Value) -> Revolve {
rotation_x: num(p, "rotationX", 0.0),
rotation_y: num(p, "rotationY", 0.0),
rotation_z: num(p, "rotationZ", 0.0),
perspective: num(p, "perspective", 0.0) / 100.0,
perspective: num(p, "perspectiveAngle", 0.0),
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
light_azimuth: num(p, "lightAzimuth", -45.0),
light_elevation: num(p, "lightElevation", 45.0),
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
smooth_shading: true,
cap_ends: flag(p, "cap", true),
}
}
@@ -50,6 +54,9 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve cap must be a boolean".into());
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
}
@@ -93,7 +100,10 @@ fn surface(
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
.flatten();
let faces = visible.unwrap_or_else(|| {
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
.collect()
});
let children = faces
.into_iter()
@@ -102,13 +112,17 @@ fn surface(
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let s = face.brightness as f32;
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
items.push(AppearanceItem::Fill(fill));
} else {
items.push(AppearanceItem::Fill(fill));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - face.brightness) as f32;
if let Some(g) = face.gradient {
shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32);
} else {
shadow.opacity = (1.0 - face.brightness) as f32;
}
items.push(AppearanceItem::Fill(shadow));
}
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
@@ -119,6 +133,9 @@ fn surface(
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
// Acute face corners must stay within the overlap width. Miter joins
// amplify this seam stroke into visible spikes along the silhouette.
edge.join = LineJoin::Round;
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
@@ -132,6 +149,15 @@ fn surface(
Some(group)
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops =
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
@@ -222,6 +248,85 @@ mod tests {
crate::new_effect(REVOLVE, &json!({})).unwrap()
}
#[test]
fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() {
use kurbo::Shape;
for shade in [false, true] {
for angle in [110.0, 360.0] {
let mut n = profile_node();
n.appearance.effects.push(
crate::new_effect(
REVOLVE,
&json!({
"offset":20,"rotationX":-18,"segments":24,"cap":true,
"shade":shade,"angle":angle
}),
)
.unwrap(),
);
for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] {
let mut checked = 0;
art.walk(&mut |face| {
let Some(path) = face.path_data() else { return };
for item in &face.appearance.items {
let AppearanceItem::Stroke(stroke) = item else { continue };
let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005);
let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box();
assert!(
bounds.contains_rect(ink),
"seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}"
);
checked += 1;
}
});
assert!(checked > 20);
}
}
}
}
#[test]
fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() {
let mut n = profile_node();
n.appearance.effects.push(fx());
assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
let smooth = revolve_art(&n).unwrap();
assert!(
smooth.children().unwrap()[0].children().unwrap().iter().any(|face| {
matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_)))
})
);
n.appearance.effects[0].params["smoothShading"] = json!(false);
assert_eq!(revolve_art(&n), Some(smooth.clone()));
n.appearance.effects[0].params["smoothShading"] = json!(true);
assert_eq!(revolve_art(&n), Some(smooth));
use vectorcraft_doc::color::{Gradient, GradientPaint};
let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
n.appearance.set_fill(base.clone());
let art = revolve_art(&n).unwrap();
let faces = art.children().unwrap()[0].children().unwrap();
assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base)));
assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| {
matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity))
})));
assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces");
}
#[test]
fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() {
let mut n = profile_node();
let mut effect = fx();
effect.params["offset"] = json!(20);
effect.params["rotationX"] = json!(-35);
effect.params.as_object_mut().unwrap().remove("cap");
n.appearance.effects.push(effect);
let capped = revolve_art(&n).unwrap();
n.appearance.effects[0].params["cap"] = json!(false);
let uncapped = revolve_art(&n).unwrap();
assert_ne!(uncapped, capped);
n.appearance.effects[0].params["cap"] = json!(true);
assert_eq!(revolve_art(&n), Some(capped));
n.appearance.effects[0].params["cap"] = json!(false);
assert_eq!(revolve_art(&n), Some(uncapped));
}
#[test]
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
let mut n = profile_node();
let before = n.path_data().unwrap().clone();
+19 -15
View File
@@ -61,10 +61,12 @@ pub fn view_options(p: &Value) -> View {
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
perspective: num(p, "perspective", 0.0) / 100.0,
light_azimuth: num(p, "lightAzimuth", -45.0),
light_elevation: num(p, "lightElevation", 45.0),
perspective: num(p, "perspectiveAngle", 0.0),
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
}
@@ -111,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
}
}
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, bounds, view),
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
}
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let (path, _) =
let (path, rule) =
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
if let Some(v) = p.get("bevel")
@@ -132,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
facets(id, &path, b, p).map(|_| ())
facets(id, &path, rule, b, p).map(|_| ())
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
@@ -183,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
}
}
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
/// stroke's outline (whose contours nest as its holes do) for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
match item {
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
_ => path.clone(),
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
_ => (path.clone(), rule),
}
}
@@ -202,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
let mut out = vec![];
for item in items {
let (paint, opacity, blend) = paint_of(item);
let f = facets(ROTATE, &region(path, rule, item), b, &p).ok()?;
let (area, rule) = region(path, rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
}
return Some(out);
@@ -211,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
let (paint, opacity, blend) = paint_of(item);
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, b, &p).ok()?;
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
}
+27 -1
View File
@@ -116,10 +116,36 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
(None, None) => plain_outline(line, &style(st, width), tol),
(None, None) => {
let mut out = plain_outline(line, &style(st, width), tol);
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
out
}
}
}
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
/// (SVG 2, "Path data": zero-length path segments).
fn point_dots(line: &BezPath) -> Vec<Dot> {
let els = line.elements();
subpaths(line)
.into_iter()
.filter_map(|(r, _)| {
let sub = els.get(r)?;
let at = sub.first()?.end_point()?;
let still = sub.iter().all(|e| match e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
PathEl::QuadTo(a, b) => *a == at && *b == at,
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
PathEl::ClosePath => true,
});
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
})
.collect()
}
/// `line` stroked with `style`, to fill with the non-zero rule.
///
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
+127 -23
View File
@@ -3,7 +3,7 @@
use serde_json::Value;
use vectorcraft_geom::kurbo;
use vectorcraft_geom::shapes::{self, KAPPA};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners};
use crate::util::*;
@@ -24,15 +24,21 @@ pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData {
}
}
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`.
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`. A corner
/// whose anchor carries a handle (where a curve meets a line, or two curves meet) gets Live
/// Corners' round cut instead ([`corners::cut_corners`], #1144): it meets each side `radius` from
/// the corner, measured along the side, and trims a curved side to what is left of it.
pub fn round_corners(path: &PathData, radius: f64) -> PathData {
if radius <= 0.0 {
return path.clone();
}
let mut subs = vec![];
// Per anchor of the result: an anchor with a handle, kept as it was, for the cut below.
let mut with_handle = vec![];
for sp in &path.subpaths {
let n = sp.anchors.len();
if n < 3 && (n != 2 || sp.closed) {
with_handle.extend(sp.anchors.iter().map(|a| a.has_in() || a.has_out()));
subs.push(sp.clone());
continue;
}
@@ -41,6 +47,7 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
let a = sp.anchors[i];
let interior = sp.closed || (i > 0 && i + 1 < n);
if !interior || a.has_in() || a.has_out() {
with_handle.push(a.has_in() || a.has_out());
out.push(a);
continue;
}
@@ -52,12 +59,14 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
let (vp, vn) = (tp - a.p, tn - a.p);
let (lp, ln) = (vp.hypot(), vn.hypot());
if lp < 1e-9 || ln < 1e-9 {
with_handle.push(false);
out.push(a);
continue;
}
let (up, un) = (vp / lp, vn / ln);
// Nearly straight: nothing to round.
if up.dot(un) < -0.9999 {
with_handle.push(false);
out.push(a);
continue;
}
@@ -65,12 +74,36 @@ pub fn round_corners(path: &PathData, radius: f64) -> PathData {
let pa = a.p + up * d;
let pb = a.p + un * d;
let k = d * KAPPA;
with_handle.extend([false, false]);
out.push(Anchor::with_handles(pa, pa, pa - up * k));
out.push(Anchor::with_handles(pb, pb - un * k, pb));
}
subs.push(SubPath::new(out, sp.closed));
}
PathData::new(subs)
let rounded = PathData::new(subs);
// The corners left: those whose anchor has a handle. Their cut reaches `radius` along each
// side, as above; setback = r · (1 + cos) / sin, solved for the arc's radius r.
let mut radii = vec![0.0; with_handle.len()];
for c in corners::path_corners(&rounded) {
if with_handle.get(c.index) == Some(&true)
&& let Some(r) = radii.get_mut(c.index)
{
*r = radius * c.sin() / (1.0 + c.cos());
}
}
if radii.iter().all(|r| *r == 0.0) {
return rounded;
}
let (mut out, sources) = corners::cut_corners(&rounded, &radii, &[]);
// `cut_corners` starts a closed subpath whose first corner it cuts at the end of that cut;
// start at its start instead, as the rounding above does, so what depends on where a path
// starts (dashes, arrowheads after the effect) doesn't move.
for (sp, from) in out.subpaths.iter_mut().zip(&sources) {
if sp.closed && sp.anchors.len() > 1 && from.first() == from.last() {
sp.anchors.rotate_right(1);
}
}
out
}
fn flatten(path: &PathData) -> Vec<Vec<Point>> {
@@ -94,15 +127,37 @@ fn flatten(path: &PathData) -> Vec<Vec<Point>> {
polys
}
/// Scribble (simplified): a single zig-zag hatching across the filled area at `angle`, outlined
/// to `strokeWidth` so the fill paints it.
/// Whether segment `p`–`q` properly crosses an edge of `polys`: its own ends, which lie on the
/// outline, don't count, nor does running along an edge.
fn crosses_outline(p: Point, q: Point, polys: &[Vec<Point>]) -> bool {
let (p, q) = (p.lerp(q, 1e-3), q.lerp(p, 1e-3));
let side = |a: Point, b: Point, c: Point| (b - a).cross(c - a);
// Strictly on opposite sides, beyond rounding noise (the sides are areas: length × length).
let apart = |s1: f64, s2: f64, eps: f64| (s1 > eps && s2 < -eps) || (s1 < -eps && s2 > eps);
let pq = (q - p).hypot();
polys.iter().any(|pl| {
pl.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
let eps = 1e-9 * pq * (b - a).hypot();
apart(side(p, q, a), side(p, q, b), eps) && apart(side(a, b, p), side(a, b, q), eps)
})
})
}
/// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region
/// of the shape, outlined to `strokeWidth` so the fill paints it. `overlapVariation` moves each
/// line's ends along it by up to that many points, `spacingVariation` moves them across it, and
/// each turn's curviness moves from `curviness` by up to `curvinessVariation` percent.
pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let angle = num(p, "angle", 30.0).to_radians();
let overlap = num(p, "overlap", 0.0);
let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0);
let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0;
let curviness = num(p, "curviness", 5.0).clamp(0.0, 100.0);
let curvy = curviness / 100.0;
let curvy_var = num(p, "curvinessVariation", 0.0).clamp(0.0, 100.0);
let spacing = num(p, "spacing", 5.0).max(0.1);
let variation = num(p, "variation", 0.5).clamp(0.0, 100.0);
let along = num(p, "overlapVariation", 0.5).clamp(0.0, 100.0);
let across = num(p, "spacingVariation", 0.5).clamp(0.0, 100.0);
let seed = seed(p);
let c = b.center();
let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2());
@@ -117,7 +172,18 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
return PathData::default();
}
let step = spacing.max((y1 - y0) / 2000.0);
let mut pts: Vec<Point> = vec![];
// One zig-zag per region (#1648): a span continues a chain of the previous row as long as the
// line from that row's end to this one's start stays inside the shape; otherwise it starts a
// chain of its own. So no line crosses a hole or a notch. In a convex shape that line always
// stays inside: one chain, as before.
struct Chain {
pts: Vec<Point>,
// Each point's curviness noise in [-1, 1].
bends: Vec<f64>,
end: Point,
row: u64,
}
let mut chains: Vec<Chain> = vec![];
let mut y = y0 + step / 2.0;
let mut row = 0u64;
while y < y1 {
@@ -131,31 +197,69 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
}
}
xs.sort_by(f64::total_cmp);
let mut spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
if row % 2 == 1 {
spans.reverse();
}
let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
let open = chains.len();
for (a, bx) in spans {
let jitter = |k: u64| variation * noise(seed, row, k, 3);
let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3)));
if row.is_multiple_of(2) {
pts.push(l);
pts.push(r);
} else {
pts.push(r);
pts.push(l);
let jitter = |k: u64, v: f64| v * noise(seed, row, k, 3);
let (l, r) = (Point::new(a + jitter(0, along), y + jitter(1, across)), Point::new(bx + jitter(2, along), y + jitter(3, across)));
let even = row.is_multiple_of(2);
let pair = if even { [l, r] } else { [r, l] };
let bend = [noise(seed, row, 4, 3), noise(seed, row, 5, 3)];
let bend = if even { bend } else { [bend[1], bend[0]] };
// Un-jittered ends of the span in drawing order.
let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) };
// The nearest chain of the previous row that this span can join without leaving
// the shape.
let prev = row.checked_sub(1);
let pick = (0..open)
.filter(|&k| Some(chains[k].row) == prev && !crosses_outline(chains[k].end, start, &polys))
.min_by(|&i, &j| (chains[i].end - start).hypot().total_cmp(&(chains[j].end - start).hypot()));
match pick {
Some(k) => {
let c = &mut chains[k];
c.pts.extend(pair);
c.bends.extend(bend);
(c.end, c.row) = (end, row);
}
None => chains.push(Chain { pts: pair.to_vec(), bends: bend.to_vec(), end, row }),
}
}
row += 1;
y += step;
}
if pts.len() < 2 {
let lines: Vec<SubPath> = chains
.into_iter()
.filter(|c| c.pts.len() >= 2)
.map(|c| {
if curvy_var > 0.0 {
varied_curves(&c.pts, &c.bends, curviness, curvy_var)
} else if curvy > 0.0 {
catmull_rom(&c.pts, false, curvy * 2.0)
} else {
SubPath::polyline(&c.pts, false)
}
})
.collect();
if lines.is_empty() {
return PathData::default();
}
let line = if curvy > 0.0 { catmull_rom(&pts, false, curvy * 2.0) } else { SubPath::polyline(&pts, false) };
let mut center = PathData::single(line);
let mut center = PathData::new(lines);
center.transform(to_local);
let stroke = kurbo::Stroke::new(width).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round);
let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05);
PathData::from_bezpath(&outline)
}
/// A scribble line through `pts` whose curviness at each point is `curviness` percent plus
/// `variation` percent times that point's `bends` noise (in -1..1), kept within 0 to 100 percent.
fn varied_curves(pts: &[Point], bends: &[f64], curviness: f64, variation: f64) -> SubPath {
// A tension of 6 makes each handle the whole Catmull-Rom tangent; each point then scales its
// own to the tension `catmull_rom` takes for that curviness (curviness / 100 × 2).
let mut line = catmull_rom(pts, false, 6.0);
for (a, bend) in line.anchors.iter_mut().zip(bends) {
let k = (curviness + variation * bend).clamp(0.0, 100.0) / 300.0;
a.h_in = a.p + (a.h_in - a.p) * k;
a.h_out = a.p + (a.h_out - a.p) * k;
}
line
}
+453 -2
View File
@@ -1,6 +1,6 @@
use serde_json::{Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
@@ -8,6 +8,26 @@ fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// Every image under `n`, in document order (its placement's x).
fn image_xs(n: &Node, out: &mut Vec<f64>) {
if let NodeKind::Image(im) = &n.kind {
out.push(im.xf.as_coeffs()[4]);
}
for c in n.children().into_iter().flatten() {
image_xs(c, out);
}
}
/// Every symbol instance under `n`, in document order (its placement's x).
fn instance_xs(n: &Node, out: &mut Vec<f64>) {
if let NodeKind::SymbolInstance { xf, .. } = &n.kind {
out.push(xf.as_coeffs()[4]);
}
for c in n.children().into_iter().flatten() {
instance_xs(c, out);
}
}
fn square() -> PathData {
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
}
@@ -206,12 +226,204 @@ fn random_transform_is_stable_per_object_and_within_the_values() {
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
}
/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded,
/// whatever each one's count.
#[test]
fn stacked_transform_copies_stay_within_the_anchor_budget() {
let p = json!({"copies": 1000});
let b = square().bounds().unwrap();
let mut path = square();
let mut counts = vec![];
for _ in 0..3 {
path = crate::distort::transform_within(&path, b, &p, 0, 100);
counts.push(path.anchor_count());
}
assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them");
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25);
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
}
/// #2042: stacked Transform effects multiply their image copies as they do their anchors; the
/// image nodes they make are bounded, whatever each one's count.
#[test]
fn stacked_transform_copies_stay_within_the_image_budget() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
let image = || {
Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
)
};
let count = |art: &Node| {
let mut xs = vec![];
image_xs(art, &mut xs);
xs.len()
};
let m = Affine::translate((70.0, 0.0));
let mut art = Node::group(id, vec![Arc::new(image())]);
let mut counts = vec![];
for _ in 0..3 {
crate::reshape::transform_placed_within(&mut art, m, 1000, 100);
counts.push(count(&art));
}
assert_eq!(counts, [100, 100, 100], "99 copies, then no room for a copy of them");
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
let mut art = Node::group(id, vec![Arc::new(image())]);
crate::reshape::transform_placed_within(&mut art, m, 24, 100);
assert_eq!(count(&art), 25);
let mut g = Node::group(id, vec![Arc::new(image())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000})));
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
}
#[test]
fn transform_copies_stop_before_they_overflow() {
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len());
}
/// #2023: a Transform effect on a group moves, scales and copies its images like its paths: the
/// map goes into each image's placement, and every copy is an image node of its own.
#[test]
fn transform_on_a_group_moves_and_copies_its_images() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeId, NodeKind};
let id = NodeId(9);
let sq = Node::path(id, square(), Appearance::default());
let image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
);
let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
let art = evaluate_container(&g).unwrap();
let mut xs = vec![];
image_xs(&art, &mut xs);
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [10.0, 80.0, 150.0], "the image and its two copies, 70 pt apart");
// Without copies the image itself takes the transform, as a path does.
let mut g = Node::group(
id,
vec![Arc::new(Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
))],
);
g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8})));
let art = evaluate_container(&g).unwrap();
let mut xs = vec![];
image_xs(&art, &mut xs);
// The bounds are the image itself, so the bottom-right reference is its own: it halves in place.
assert_eq!(xs, [30.0], "scaled about its own bottom-right corner");
}
/// #2023: a Transform effect on a group moves and copies its symbol instances as it does its paths
/// and images: the map goes into each instance's placement, and every copy is an instance of its
/// own.
#[test]
fn transform_on_a_group_moves_and_copies_its_symbol_instances() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
let id = NodeId(9);
let instance = || Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::translate((5.0, 40.0)) });
let xs = |art: &Node| {
let mut xs = vec![];
instance_xs(art, &mut xs);
xs.sort_by(f64::total_cmp);
xs
};
let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
// Without copies the instance itself takes the transform, as a path does.
let mut g = Node::group(id, vec![Arc::new(instance())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70})));
assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]);
}
/// Images and symbol instances share the copy budget: copies of one leave less room for the other.
#[test]
fn images_and_symbol_instances_share_the_copy_budget() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
let image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
);
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
let mut art = Node::group(id, vec![Arc::new(image), Arc::new(instance)]);
crate::reshape::transform_placed_within(&mut art, Affine::translate((70.0, 0.0)), 1000, 100);
let (mut images, mut instances) = (vec![], vec![]);
image_xs(&art, &mut images);
instance_xs(&art, &mut instances);
// The image takes the 98 copies there is room for; none is left for the instance.
assert_eq!((images.len(), instances.len()), (99, 1));
}
/// As a path's do, a Transform effect's copies of an image stop before one reaches further than
/// `MAX_REACH` from the origin.
#[test]
fn transform_copies_of_images_stop_at_the_reach_limit() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
let image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 40,
height: 40,
xf: Affine::translate((10.0, 0.0)),
link: None,
placement: Default::default(),
})),
);
let mut g = Node::group(id, vec![Arc::new(image)]);
// The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9.
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000})));
let mut xs = vec![];
image_xs(&evaluate_container(&g).unwrap(), &mut xs);
assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach");
assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9));
}
#[test]
fn pucker_bloat_moves_anchors_in_opposite_directions() {
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
@@ -236,6 +448,44 @@ fn roughen_seed_changes_result_and_detail_adds_points() {
assert!(bb.x0 > -8.0 && bb.x1 < 108.0);
}
#[test]
fn catmull_rom_keeps_a_turn_back_smooth() {
// #1643: the path runs out to (10, 0) and turns back to (0.5, 0.2). The Catmull-Rom tangent
// `next - prev` is almost zero there; the anchor gets handles along the chords' bisector
// (here nearly perpendicular to them), collinear and as long as half the longer chord / 6.
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.5, 0.2), Point::new(10.0, 5.0)];
let sp = util::catmull_rom(&pts, false, 1.0);
let a = sp.anchors[1];
let (hin, hout) = (a.h_in - a.p, a.h_out - a.p);
assert!(close(hin.x, -hout.x, 1e-9) && close(hin.y, -hout.y, 1e-9), "collinear: {a:?}");
assert!(close(hout.hypot(), 0.5 * 10.0 / 6.0, 1e-3), "{a:?}");
assert!(hout.x.abs() < 0.05 * hout.hypot(), "across the chords: {a:?}");
// A regular turn keeps the plain Catmull-Rom tangent.
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)];
let a = util::catmull_rom(&pts, false, 1.0).anchors[1];
let (got, want) = (a.h_out - a.p, (pts[2] - pts[0]) / 6.0);
assert!(close(got.x, want.x, 1e-12) && close(got.y, want.y, 1e-12), "{got:?} vs {want:?}");
}
#[test]
fn roughen_smooth_makes_no_cusps() {
// #1643: wherever Roughen's jitter turns the path back, the smooth points stay smooth: no
// anchor's handles shrink below 1/12 of its longer chord (a cusp that PDF viewers miter).
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 5, 0.0);
for seed in 0..50 {
let out = run("distort.roughen", json!({"size": 3, "detail": 10, "seed": seed}), &star);
for sp in &out.subpaths {
let n = sp.anchors.len();
for i in 0..n {
let a = sp.anchors[i];
let (prev, next) = (sp.anchors[(i + n - 1) % n].p, sp.anchors[(i + 1) % n].p);
let chord = (a.p - prev).hypot().max((next - a.p).hypot());
assert!((a.h_out - a.p).hypot() >= chord / 12.0 - 1e-9, "seed {seed}: cusp at {:?}", a.p);
}
}
}
}
#[test]
fn zigzag_ridge_count_and_amplitude() {
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
@@ -244,6 +494,62 @@ fn zigzag_ridge_count_and_amplitude() {
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
}
#[test]
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
// #1554: on a straight line every ridge is the same distance from the line and as far from
// the next one, whether the points are corners or smooth.
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
for points in ["corner", "smooth"] {
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(ps.len(), 6, "{points}");
for (k, q) in ps.iter().enumerate() {
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
}
}
}
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
/// turn all the way round.
#[test]
fn zigzag_on_a_circle_is_regular() {
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts.len(), 16);
let start = pts[0].y.atan2(pts[0].x);
let mut radii = vec![];
for (i, q) in pts.iter().enumerate() {
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
radii.push(q.to_vec2().hypot().round());
}
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
}
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
#[test]
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let curve = PathData::single(SubPath::new(
vec![
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
],
false,
));
let c = curve.subpaths[0].segment(0);
let len = c.arclen(1e-6);
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
assert_eq!(along.len(), 5);
for (k, share) in along.iter().enumerate() {
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
}
}
#[test]
fn offset_path_grows_and_shrinks() {
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
@@ -288,6 +594,66 @@ fn round_corners_replaces_corners() {
assert_eq!(run("stylize.roundCorners", json!({}), &el), el);
}
/// A D: a straight back, a curved bowl, and two corners where the bowl meets the straight
/// sides (the anchors there carry the bowl's handles).
fn d_shape() -> PathData {
use vectorcraft_geom::Anchor;
let p = Point::new;
PathData::new(vec![SubPath::new(
vec![
Anchor::corner(p(0.0, 0.0)),
Anchor::with_handles(p(50.0, 0.0), p(50.0, 0.0), p(80.0, 20.0)),
Anchor::with_handles(p(50.0, 100.0), p(80.0, 80.0), p(50.0, 100.0)),
Anchor::corner(p(0.0, 100.0)),
],
true,
)])
}
#[test]
fn round_corners_also_rounds_where_a_curve_meets_a_line() {
let d = d_shape();
let out = run("stylize.roundCorners", json!({"radius": 8}), &d);
// All four corners are cut, the two curve-line ones too: two anchors each.
assert_eq!(out.anchor_count(), 8);
assert!(all_finite(&out));
// Every cut leaves both sides smoothly: no corner is left anywhere.
assert!(vectorcraft_geom::corners::path_corners(&out).is_empty(), "{out:?}");
let (before, after) = (d.to_bezpath().area().abs(), out.to_bezpath().area().abs());
assert!(after < before && after > before - 4.0 * 8.0 * 8.0, "{before} -> {after}");
// The cut at a curve-line corner meets the straight side 8 from the corner.
let p = Point::new;
assert!(out.subpaths[0].anchors.iter().any(|a| a.p.distance(p(42.0, 0.0)) < 1e-9), "{out:?}");
}
#[test]
fn round_corners_keeps_straight_corners_as_they_were() {
// The D's straight corners are rounded as before, next to the new cuts: same anchors, same
// quarter-circle handles, and the path still starts at its first corner's rounding.
use vectorcraft_geom::Anchor;
let p = Point::new;
let k = 8.0 * shapes::KAPPA;
let out = stylize::round_corners(&d_shape(), 8.0);
let a = &out.subpaths[0].anchors;
assert_eq!(a[0], Anchor::with_handles(p(0.0, 8.0), p(0.0, 8.0), p(0.0, 8.0 - k)));
assert_eq!(a[1], Anchor::with_handles(p(8.0, 0.0), p(8.0 - k, 0.0), p(8.0, 0.0)));
// A path of straight corners only is rounded exactly as before.
assert_eq!(stylize::round_corners(&square(), 10.0).subpaths[0].anchors.len(), 8);
}
#[test]
fn round_corners_keeps_where_a_closed_path_starts() {
// The D starting at a curve-line corner: the result starts where that corner's cut begins,
// on the side coming from the previous corner, as a straight corner's rounding does.
let mut d = d_shape();
d.subpaths[0].anchors.rotate_left(1);
let out = stylize::round_corners(&d, 8.0);
let start = out.subpaths[0].anchors[0].p;
assert!(start.distance(Point::new(42.0, 0.0)) < 1e-9, "{start:?}");
// An infinite radius rounds as much as fits, curve-line corners too.
assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9));
}
#[test]
fn twist_fixes_the_outer_circle_and_rotates_inside() {
let out = run("distort.twist", json!({"angle": 0}), &square());
@@ -298,6 +664,23 @@ fn twist_fixes_the_outer_circle_and_rotates_inside() {
assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}");
}
#[test]
fn scribble_does_not_cross_holes() {
// #1648: a U (opening at the top, the notch x 30..70, y 0..60). Rows (y 2.5, 7.5, ...) through the prongs have
// two spans; their zig-zags must stay apart instead of crossing the notch.
let u = [(0.0, 0.0), (30.0, 0.0), (30.0, 60.0), (70.0, 60.0), (70.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let u = PathData::single(SubPath::polyline(&u.map(|(x, y)| Point::new(x, y)), true));
let params = json!({"angle": 0, "variation": 0, "curviness": 0, "strokeWidth": 2, "spacing": 5});
let out = run("stylize.scribble", params, &u).to_bezpath();
for x in [40.0, 50.0, 60.0] {
for y in [12.5, 32.5, 52.5] {
assert_eq!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(x, y)), 0, "scribble covers the notch at ({x}, {y})");
}
}
// The bar below the notch is still hatched.
assert_ne!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(50.0, 82.5)), 0);
}
#[test]
fn scribble_produces_filled_outline_inside_bounds() {
let out = run("stylize.scribble", json!({}), &square());
@@ -307,6 +690,59 @@ fn scribble_produces_filled_outline_inside_bounds() {
assert_eq!(out, run("stylize.scribble", json!({}), &square()));
}
#[test]
fn scribble_variations_are_separate() {
// At 0° the lines run along x: the Path Overlap variation moves their ends in x only, the
// Spacing variation moves them in y only, and the Curviness variation curves some of the turns.
let base =
json!({"angle": 0, "curviness": 0, "strokeWidth": 0.5, "spacing": 10, "overlapVariation": 0, "spacingVariation": 0, "curvinessVariation": 0});
let with = |key: &str, v: f64| {
let mut p = base.clone();
p[key] = json!(v);
run("stylize.scribble", p, &square())
};
let plain = run("stylize.scribble", base.clone(), &square());
let b = plain.bounds().unwrap();
let moved = |a: f64, b: f64| (a - b).abs() > 0.5;
let along = with("overlapVariation", 4.0).bounds().unwrap();
assert!(close(along.y0, b.y0, 0.06) && close(along.y1, b.y1, 0.06), "{along:?} {b:?}");
assert!(moved(along.x0, b.x0) || moved(along.x1, b.x1), "{along:?} {b:?}");
let across = with("spacingVariation", 4.0).bounds().unwrap();
assert!(close(across.x0, b.x0, 0.06) && close(across.x1, b.x1, 0.06), "{across:?} {b:?}");
assert!(moved(across.y0, b.y0) || moved(across.y1, b.y1), "{across:?} {b:?}");
assert_ne!(with("curvinessVariation", 50.0), plain);
// On a curviness of its own, the variation changes how much each turn curves.
let mut p = base.clone();
p["curviness"] = json!(20);
let even = run("stylize.scribble", p.clone(), &square());
p["curvinessVariation"] = json!(20);
assert_ne!(run("stylize.scribble", p, &square()), even);
}
#[test]
fn scribble_reads_its_earlier_single_variation() {
// Before the separate variations, one `variation` moved the ends both along and across.
let old = run("stylize.scribble", json!({"variation": 3}), &square());
assert_eq!(old, run("stylize.scribble", json!({"overlapVariation": 3, "spacingVariation": 3}), &square()));
assert_eq!(upgrade_params("stylize.scribble", &json!({"variation": 3})), json!({"overlapVariation": 3, "spacingVariation": 3}));
// A current key given with it wins.
assert_eq!(
upgrade_params("stylize.scribble", &json!({"variation": 3, "spacingVariation": 1})),
json!({"overlapVariation": 3, "spacingVariation": 1})
);
assert!(default_params("stylize.scribble").unwrap().get("variation").is_none());
}
#[test]
fn scribble_angle_is_stored_above_minus_180_and_up_to_180() {
for (given, stored) in [(270.0, -90.0), (-180.0, 180.0), (180.0, 180.0), (540.0, 180.0), (-270.0, 90.0), (-200.0, 160.0)] {
assert_eq!(new_effect("stylize.scribble", &json!({"angle": given})).unwrap().params["angle"].as_f64(), Some(stored), "{given}");
}
// An angle in range is kept as given; other effects' angles are not changed.
assert_eq!(upgrade_params("stylize.scribble", &json!({"angle": 45})), json!({"angle": 45}));
assert_eq!(upgrade_params("distort.twist", &json!({"angle": 270})), json!({"angle": 270}));
}
#[test]
fn effects_chain_in_order_and_skip_hidden_and_raster() {
let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))];
@@ -333,11 +769,12 @@ fn raster_effects_parse_and_outset() {
let r = raster_effects(&list);
assert_eq!(r.len(), 5);
match &r[0] {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => {
assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply);
assert!(close(*opacity as f64, 0.5, 1e-6));
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
assert_eq!(*darkness, None);
}
other => panic!("{other:?}"),
}
@@ -346,6 +783,20 @@ fn raster_effects_parse_and_outset() {
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
}
#[test]
fn drop_shadow_darkness_replaces_its_color_when_chosen() {
let darkness = |params: Value| match raster_effects(&[fx("stylize.dropShadow", params)]).as_slice() {
[RasterFx::DropShadow { darkness, .. }] => *darkness,
other => panic!("{other:?}"),
};
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 60})), Some(0.6));
assert_eq!(darkness(json!({"shade": "darkness"})), Some(1.0));
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 250})), Some(1.0));
// Color is the default: a Darkness value alone leaves the shadow its color.
assert_eq!(darkness(json!({"darkness": 60})), None);
assert_eq!(darkness(json!({})), None);
}
#[test]
fn blend_mode_names() {
use vectorcraft_doc::color::BlendMode;
+75 -1
View File
@@ -146,11 +146,85 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
assert!(ids.len() > 20, "{} nodes", ids.len());
}
/// Files and scripts from before the Perspective angle give `perspective` as a 0–100 % strength:
/// each 3D effect draws it exactly as the angle that stands for it, an angle given too wins, and
/// the current parameters carry only the angle.
#[test]
fn an_earlier_perspective_strength_draws_as_its_angle() {
let art = |n: &Node, id: &str| if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) };
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
let view = |extra: Value| {
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 80});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let with = |params: Value| {
let mut n = square_node();
// As read from a file: the stored parameters, not a fresh effect's.
n.appearance.effects.push(Effect { id: id.into(), params, visible: true });
art(&n, id).unwrap().visual_bounds().unwrap()
};
let angle = vectorcraft_three_d::perspective_from_strength(0.6);
let old = with(view(json!({"perspective": 60})));
let new = with(view(json!({"perspectiveAngle": angle})));
let none = with(view(json!({})));
for (a, b) in [(old.x0, new.x0), (old.y0, new.y0), (old.x1, new.x1), (old.y1, new.y1)] {
assert!((a - b).abs() < 1e-9, "{id}: {old:?} against {new:?}, none {none:?}");
}
assert!((old.width() - none.width()).abs() > 1.0 || (old.height() - none.height()).abs() > 1.0, "{id}: perspective shows");
assert_eq!(with(view(json!({"perspective": 60, "perspectiveAngle": 0}))), none, "{id}: the angle wins");
let merged = crate::merged_params(id, &view(json!({"perspective": 60})));
assert!(merged.get("perspective").is_none() && (merged["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{id}");
}
}
/// Effects from before Rotation and Height set the light by azimuth and elevation: each 3D effect
/// shades its faces exactly as with the Rotation, Height and side that stand for it.
#[test]
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
&& let Some(c) = f.paint.color()
{
out.push(c.to_rgb());
}
for c in n.children().into_iter().flatten() {
colours(c, out);
}
}
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
for (azimuth, elevation) in [(-45.0, 45.0), (150.0, -30.0), (60.0, 80.0), (-120.0, 10.0)] {
let with = |light: Value| {
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 60, "lightSoftness": 0});
p.as_object_mut().unwrap().extend(light.as_object().unwrap().clone());
let mut n = square_node();
n.appearance.effects.push(Effect { id: id.into(), params: p, visible: true });
let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { solid_art(&n) }.unwrap();
let mut out = vec![];
colours(&art, &mut out);
out
};
let (r, h, behind) = vectorcraft_three_d::light_from_azimuth_elevation(azimuth, elevation);
let old = with(json!({"lightAzimuth": azimuth, "lightElevation": elevation}));
let new = with(json!({"lightRotation": r, "lightHeight": h, "lightBehind": behind}));
assert!(!old.is_empty() && old.len() == new.len(), "{id}");
for (a, b) in old.iter().zip(&new) {
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "{id} {azimuth} {elevation}: {a:?} against {b:?}");
}
}
let merged = crate::merged_params(id, &json!({"lightAzimuth": 90, "lightElevation": 0}));
assert!(
merged.get("lightAzimuth").is_none() && merged["lightHeight"].as_f64().unwrap().abs() < 1e-9 && merged["lightBehind"] == json!(false),
"{id}: {merged}"
);
}
}
#[test]
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
let mut n = square_node();
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 0})));
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
// Straight on, the extrusion shows its front: the offset square.
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
+27 -2
View File
@@ -72,7 +72,7 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`).
pub use vectorcraft_doc::live::map_nonlinear;
/// Smooth subpath through `pts` (Catmull-Rom tangents).
/// Smooth subpath through `pts` (Catmull-Rom tangents, see [`turn_tangent`] for sharp turns).
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
let n = pts.len();
if n < 3 {
@@ -82,13 +82,38 @@ pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
.map(|i| {
let prev = if i == 0 { if closed { pts[n - 1] } else { pts[0] } } else { pts[i - 1] };
let next = if i == n - 1 { if closed { pts[0] } else { pts[n - 1] } } else { pts[i + 1] };
let t: Vec2 = (next - prev) * (tension / 6.0);
let t: Vec2 = turn_tangent(prev, pts[i], next) * (tension / 6.0);
Anchor::with_handles(pts[i], pts[i] - t, pts[i] + t)
})
.collect();
SubPath::new(anchors, closed)
}
/// The Catmull-Rom tangent `next − prev` at `p`. Where the path turns back sharply, `next`
/// nearly meets `prev` and that tangent shrinks to nothing: the anchor would be a cusp, which
/// PDF viewers draw with a miter spike (#1643). So it is kept at least half as long as the
/// longer chord, and there it follows the bisector of the two chords (the perpendicular of the
/// incoming one when the path reverses exactly): the turn becomes a round hairpin. Turns of
/// less than about 150° keep the plain Catmull-Rom tangent.
fn turn_tangent(prev: Point, p: Point, next: Point) -> Vec2 {
let t = next - prev;
let (a, b) = (p - prev, next - p);
let floor = 0.5 * a.hypot().max(b.hypot());
if t.hypot() >= floor {
return t;
}
let unit = |v: Vec2| {
let l = v.hypot();
if l > 1e-12 { v / l } else { Vec2::ZERO }
};
let (u, v) = (unit(a), unit(b));
let mut d = unit(u + v);
if d == Vec2::ZERO {
d = Vec2::new(-u.y, u.x);
}
if d == Vec2::ZERO { t } else { d * floor }
}
/// Unit normal (left of travel direction in y-down coordinates) of cubic `c` at `t`.
pub fn normal_at(c: &CubicBez, t: f64) -> Vec2 {
let eps = 1e-4;
+2 -2
View File
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
let f = |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
(b2, a)
} else {
warp_point(style, bend, dh, dv, x, y)
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
+7
View File
@@ -8,6 +8,12 @@ description = "VectorCraft engine: sessions, history, selection, the command reg
[lints]
workspace = true
[features]
default = ["bundled-fonts"]
# The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at
# run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`.
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
[dependencies]
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
@@ -26,6 +32,7 @@ vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
+7 -7
View File
@@ -22,7 +22,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_geom::Rect;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::opacitymask::percent;
use super::paint::paint_from;
use super::*;
@@ -294,15 +294,15 @@ pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the
/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers
/// among them ([`leaf_targets`]).
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too; an id inside
/// another given one is dropped), else the [`subject_roots`]; with `target: "contents"` the painted
/// objects inside the groups and layers among them ([`leaf_targets`]).
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
subject_roots(s)?
} else {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
outermost(d, targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect())
};
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
@@ -560,7 +560,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
}
};
if let Some(pa) = &paint {
*pp = super::gradient::place_paint(pa, p, bounds);
*pp = super::gradient::place_paint(&super::paint::keep_pattern_xf(pa, p, Some(&*pp)), p, bounds);
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = percent(o);
@@ -745,7 +745,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let selected = match ids_param(p, "ids") {
Some(v) => v,
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
};
// Reversed, the first selected object's attributes go onto the clicked object.
+24 -5
View File
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{ExportAsset, NodeId};
use super::edit::{roots_of, selected_roots};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings};
use super::*;
/// The longest asset name kept (characters).
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Collect for Export",
["Object", "Collect for Export"],
None,
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
"{ids?: [id…] (default: the selected objects; a layer id collects the art under it, including in its sublayers), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
has_doc,
add
),
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assets",
[],
None,
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order",
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order",
has_doc,
list
),
@@ -78,13 +78,32 @@ fn clean_name(name: &str) -> Option<String> {
(!n.is_empty()).then_some(n)
}
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
/// (sublayers unfolded). Layers alone have nothing to collect.
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
if n.is_layer() {
if let Some(kids) = n.children() {
for c in kids {
art_under(doc, c.id, out);
}
}
return;
}
out.push(id);
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.add";
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
// An explicit list is authoritative: never silently drop malformed or stale ids.
let ids = checked_ids_param(s, p, "ids", C)?;
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
let mut art = Vec::new();
for id in &ids {
art_under(d, *id, &mut art);
}
roots_of(d, d.paint_order(art))
} else {
selected_roots(s)?
};
@@ -161,7 +180,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
.assets
.iter()
.map(|a| {
let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y));
let bounds = export_crop(d, &a.nodes);
json!({
"id": a.id,
"name": a.name,
+41 -26
View File
@@ -11,10 +11,10 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, Brush};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::*;
use crate::EngineError;
@@ -95,13 +95,13 @@ pub fn specs() -> Vec<CommandSpec> {
brush_freehand
),
// ----- symbols -----
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h]}], current}", has_doc, symbol_list),
cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8}], current}", has_doc, symbol_list),
cmd!(
"symbol.new",
"New Symbol…",
[],
None,
"{name?, ids?|id?} make a symbol from the selection and replace it with an instance → {name, id}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration)} make a symbol from the selection and replace it with an instance → {name, id, registration}",
has_selection,
symbol_new
),
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place Symbol Instance",
[],
None,
"{name?, x?, y?} place an instance centred at (x, y) (default: artboard centre; name default: current) → {id}",
"{name?, x?, y?} place an instance so its registration point is at (x, y) (default: artboard centre; name default: current) → {id}",
has_doc,
symbol_place
),
@@ -198,11 +198,11 @@ fn ids_json(ids: &[NodeId]) -> Value {
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(mut v) => {
// Explicit ids: only existing, non-layer objects.
// Explicit ids: only existing, non-layer objects, none inside another of them.
let d = &s.doc()?.doc;
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
v.dedup();
Ok(v)
Ok(outermost(d, v))
}
None => selected_roots(s),
}
@@ -511,11 +511,19 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) {
}
}
/// Instance transform for a symbol of natural size `(w, h)` centred at `c`.
fn place_xf(c: Point, (w, h): (f64, f64)) -> Affine {
/// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`.
fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine {
let c = at - reg.offset_from_center(w, h);
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF))
}
fn registration_param(p: &Value, cmd: &str) -> Result<SymbolRegistration> {
match p.get("registration") {
None => Ok(SymbolRegistration::CENTER),
Some(v) => v.as_u64().and_then(SymbolRegistration::from_u64).ok_or_else(|| bad(cmd, "registration must be 0..8")),
}
}
/// Normalise art for a symbol definition: returns (art, natural size, centre in the document).
fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?;
@@ -526,14 +534,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
Ok((art, (w, h), c))
}
fn unique_symbol_name(d: &Document, base: &str) -> String {
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
@@ -561,7 +561,12 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
.iter()
.map(|sym| {
let (w, h) = natural_size(d, &sym.name);
json!({"name": sym.name, "instances": instances_of(d, Some(&sym.name)).len(), "size": [w, h]})
json!({
"name": sym.name,
"instances": instances_of(d, Some(&sym.name)).len(),
"size": [w, h],
"registration": d.symbol_registration(&sym.name).0,
})
})
.collect();
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
@@ -587,17 +592,21 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, "symbol.new")?;
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
let id = s.edit("New Symbol", |d, sel| {
let art = d.reid(&art);
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
set_natural_size(d, &name, Some(size));
d.set_symbol_registration(&name, reg);
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
let id = replace_with_instance(d, &ids, &name, place)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0 }))
Ok(json!({ "name": name, "id": id.0, "registration": reg.0 }))
}
fn resolve_symbol(d: &Document, p: &Value, cmd: &str) -> Result<String> {
@@ -614,12 +623,13 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let name = resolve_symbol(d, p, "symbol.place")?;
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let parent = s.doc()?.target_parent()?;
let id = s.edit("Place Symbol Instance", |d, sel| {
let id = d.alloc_id();
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(at, size, reg) }))?;
sel.set([id]);
Ok(id)
})?;
@@ -728,7 +738,8 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
*xf *= fix;
}
}
let id = replace_with_instance(d, &ids, &name, place_xf(c, size))?;
let reg = d.symbol_registration(&name);
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
sel.set([id]);
Ok(id)
})?;
@@ -751,6 +762,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
d.symbols.retain(|x| x.name != name);
set_natural_size(d, &name, None);
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
}
@@ -764,12 +776,14 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, C)?.to_string();
let d = &s.doc()?.doc;
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
s.edit("Duplicate Symbol", |d, _| {
let art = d.reid(&sym.art);
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
set_natural_size(d, &new, Some(size));
d.set_symbol_registration(&new, reg);
Ok(())
})?;
Ok(json!({ "name": new }))
@@ -890,13 +904,14 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
placed.push(c);
}
let count = placed.len();
let reg = s.doc()?.doc.symbol_registration(&name);
let id = s.edit("Symbol Sprayer", |d, sel| {
let mut nodes = vec![];
for (i, c) in placed.iter().enumerate() {
let (_, _, js) = jitter(i + 7, *c);
let k = scale * (1.0 + js * 0.1);
let id = d.alloc_id();
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size) * Affine::scale(k) }));
nodes.push(Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(*c, size, reg) * Affine::scale(k) }));
}
let gid = match set {
Some(g) => {
+104 -16
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
@@ -24,7 +24,7 @@ use vectorcraft_tools::builder::{
};
use vectorcraft_trace as tr;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::*;
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_ids,
trace_set_view
),
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
}),
]
}
@@ -192,7 +192,7 @@ fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if ids.is_empty() {
return Err(EngineError::Other("select objects, not layers".into()));
}
Ok(sorted_roots(d, &ids))
Ok(outermost(d, sorted_roots(d, &ids)))
}
fn points_param(p: &Value, key: &str) -> Option<Vec<Point>> {
@@ -657,7 +657,10 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
// (A preset without an enhanced version is traced as it is.)
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
@@ -703,17 +706,93 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
/// refused with advice instead (#525).
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
use vectorcraft_geom::Affine;
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
let mut live = match shape {
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
w: width,
h: height,
radii: [radius; 4],
kinds: Default::default(),
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
},
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
}
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
}
};
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
let _ = live.transform(xf);
live
}
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
/// document by `xf`.
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had:
/// abutting paths painted in another order would show a seam where they meet. Without parents,
/// the nodes as they are.
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
let n = nodes.len();
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
let mut roots = vec![];
for i in 0..n {
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
Some(of) => of.push(i),
None => roots.push(i),
}
}
// Outermost first, so that backwards each path comes after those inside it.
let mut order = roots.clone();
let mut at = 0;
while let Some(&i) = order.get(at) {
order.extend(inside.get(i).into_iter().flatten());
at += 1;
}
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
for &i in order.iter().rev() {
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
let mut within: Vec<(usize, Arc<Node>)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect();
let made = if within.is_empty() {
node
} else {
within.push((i, Arc::new(node)));
within.sort_by_key(|m| m.0);
Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect())
};
if let Some(slot) = built.get_mut(i) {
*slot = Some(Arc::new(made));
}
}
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
// and they are kept all the same.)
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
}
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
// else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
let preset_label = match tr::preset(named) {
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(&params_json) => named.to_string(),
_ => "Custom".to_string(),
};
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(&params_json);
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
let roots = root_ids(s, p)?;
// The image (inside a trace group, or selected directly).
let (target, img) = {
@@ -763,17 +842,25 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
hidden.visible = false;
children.push(Arc::new(hidden));
}
let mut traced = Vec::with_capacity(res.paths.len());
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
// Shapes: a path the tracer found to be a shape is that live shape.
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
let shape = traced_shape(shape, xf);
*path = shape.to_path();
*live = Some(Box::new(shape));
}
// A library colour fills with the swatch's own colour (its model kept).
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
let paint = Paint::solid(color);
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
// Traced lines: round corners, and ends round or square as the tracer found them.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
@@ -782,8 +869,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
.into(),
None => fill_only(paint).into(),
};
children.push(Arc::new(n));
traced.push(n);
}
children.extend(grouped(d, traced, &res.parents));
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
+21 -9
View File
@@ -80,7 +80,8 @@ pub struct Clipboard {
pub para_styles: Vec<TextStyleDef>,
/// Brushes the objects' strokes use.
pub brushes: Vec<Brush>,
/// The artboard the objects were copied from: Paste on All Artboards keeps their offset to it.
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
/// Artboards keeps their offset to it.
pub source_artboard: Option<Rect>,
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
pub source_layers: Vec<Option<String>>,
@@ -109,8 +110,14 @@ impl Clipboard {
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
}
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
let board = bounds.and_then(|b| d.artboard_at(b.center())).unwrap_or(0);
Self { source_artboard: d.artboards.get(board).map(|a| a.rect), source_layers, source_doc: Some(st.uid), ..Self::gather(d, nodes) }
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
let board = bounds.and_then(|b| d.artboard_at(b.center()));
Self {
source_artboard: board.and_then(|i| d.artboards.get(i)).map(|a| a.rect),
source_layers,
source_doc: Some(st.uid),
..Self::gather(d, nodes)
}
}
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
@@ -168,15 +175,13 @@ impl Clipboard {
d.fix_next_id();
d
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
if self.clipboard.is_empty() {
/// The objects as standalone SVG (`None` when there are none).
pub fn svg(&self) -> Option<String> {
if self.is_empty() {
return None;
}
let d = self.clipboard.to_document();
let d = self.to_document();
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = super::rasterfx::flatten_pixel_effects(&d);
Some(vectorcraft_svg::export(
@@ -186,6 +191,13 @@ impl Session {
}
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
self.clipboard.svg()
}
}
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "svg": s.clipboard_svg() }))
}
+89 -35
View File
@@ -71,13 +71,19 @@ pub const FILE_HEAD: usize = 4096;
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
let f = fileio::detect(name, head)?;
let (asked, mime) = match f.id {
"svg" => (SVG, SVG),
// `detect` names gzip data SVGZ (a compressed native file aside); `clipboard.importSvg`
// reads SVGZ data.
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
"pdf" => (PDF, PDF),
"eps" => (EPS, EPS),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
"emf" | "wmf" => (EMF, EMF),
_ if f.raster => (BITMAP, f.mime),
_ => return None,
@@ -85,6 +91,25 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
mimes.contains(&asked).then_some(mime)
}
/// Does gzip data `head` (a file's first bytes, often a stream cut short) unpack to SVG markup?
/// The first [`FILE_HEAD`] bytes it unpacks to are read as UTF-16 after a UTF-16 byte order mark,
/// else as UTF-8, and checked with [`looks_like_svg`].
fn unpacks_to_svg(head: &[u8]) -> bool {
use std::io::Read as _;
let mut raw = Vec::with_capacity(FILE_HEAD);
// A stream cut short ends in an error after the bytes that unpacked: judge those.
let _ = flate2::read::GzDecoder::new(head).take(FILE_HEAD as u64).read_to_end(&mut raw);
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| -> String {
char::decode_utf16(rest.as_chunks::<2>().0.iter().map(|u| unit(*u))).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
let text = match raw.as_slice() {
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
_ => String::from_utf8_lossy(&raw).into_owned(),
};
looks_like_svg(&text)
}
/// One representation of the clipboard's contents.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Flavour {
@@ -192,11 +217,10 @@ fn type_only(n: &Node) -> bool {
}
}
impl Session {
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
/// only).
pub fn clipboard_text(&self) -> Option<String> {
let nodes = &self.clipboard.nodes;
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
let nodes = &self.nodes;
if nodes.is_empty() || !nodes.iter().all(type_only) {
return None;
}
@@ -211,64 +235,94 @@ impl Session {
Some(lines.join("\n"))
}
/// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when
/// it is empty or has no extent).
fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b))
/// The objects as a document whose one artboard is their visual bounds (`None` when there are
/// none or they have no extent).
fn cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
let b = self.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
/// there are none.
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
const C: &str = "clipboard.exportPng";
let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) };
let Some((doc, b)) = self.cropped() else { return Ok(None) };
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h)))
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) };
/// A one-page PDF of the objects (without the native document inside); `None` when there are
/// none.
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
let Some((doc, _)) = self.cropped() else { return Ok(None) };
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// The MIME types of [`Self::clipboard_flavours`], without making them.
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
if self.clipboard.is_empty() {
/// The MIME types of [`Self::flavours`], without making them.
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
if self.is_empty() {
return vec![];
}
let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf);
let text = svg || self.clipboard.nodes.iter().all(type_only);
let text = svg || self.nodes.iter().all(type_only);
// Art without an area (a lone guide-like line) has no page or picture.
let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
}
/// What Copy offers other apps, best first (see `clipboard.flavours`). A flavour that can't be
/// made (art too large to render) is left out; the PNG is scaled down to at most
/// [`COPY_PNG_SIDE`] pixels a side.
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
let types = self.clipboard_flavour_types();
let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten();
/// What Copy offers other apps, best first, with Include SVG Code (`svg`) and On Copy: PDF
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
for mime in types {
let data = match mime {
TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes),
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
SVG => svg.clone().map(String::into_bytes),
PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
_ => {
let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height()));
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
self.clipboard_png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
}
};
out.extend(data.map(|data| Flavour { mime, data }));
}
out
}
}
impl Session {
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
/// only).
pub fn clipboard_text(&self) -> Option<String> {
self.clipboard.text()
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
self.clipboard.png(scale)
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
self.clipboard.pdf()
}
/// The MIME types of [`Self::clipboard_flavours`], without making them.
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
self.clipboard.flavour_types(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
}
/// What Copy offers other apps, best first (see `clipboard.flavours`): [`Clipboard::flavours`]
/// by the Clipboard Handling preferences.
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
self.clipboard.flavours(self.prefs.copy_as_svg, self.prefs.copy_as_pdf)
}
/// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first
/// artboard's centre) → the number of objects.
+6
View File
@@ -204,6 +204,12 @@ impl<'a> Recolor<'a> {
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
}
/// A pass that maps solid colours and gradient stops with their swatch link and tint
/// (`links`), and meshes, images and patterns through `f`.
pub(crate) fn with_links(scope: Scope, f: &'a dyn Fn(Color) -> Color, links: &'a LinkMap<'a>) -> Self {
Self { links: Some(links), ..Self::new(scope, f) }
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
/// pattern swatches, and return the number of paints, meshes and images changed.
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
+2 -2
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Color Profile…",
[],
None,
"{path: .icc/.icm file} register an ICC profile (native only) → {name, kind}",
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
always,
load_profile
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Proof Setup",
["View", "Proof Setup"],
None,
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\"|\"tritanopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
always,
proof_setup
),
+16 -4
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -95,6 +95,12 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
/// Insert a new object looking like `look` (drawing modes apply); select it.
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
insert_look(s, label, kind, look, name, true)
}
/// [`add_look`] that selects the new object only when `select` is set; otherwise the selection
/// stays as it was.
pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>, select: bool) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let mode = s.draw_mode;
let inside = s.draw_inside;
@@ -137,7 +143,9 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr
d.insert(parent, usize::MAX, n)?;
}
}
sel.set([id]);
if select {
sel.set([id]);
}
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
@@ -519,12 +527,16 @@ pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
}
}
/// The leading model and character alignment new type starts with (`t`'s paragraph and runs):
/// `leadingModel` and `charAlign` from `p`, else em box top to top and em box centre while the
/// The paragraph alignment, leading model and character alignment new type starts with (`t`'s
/// paragraph and runs): `justify` from `p` (else as [`new_type_para`] left it), `leadingModel`
/// and `charAlign` from `p`, else em box top to top and em box centre while the
/// interface is in Japanese (#432), else the Roman baseline for both. Noted in the journal, so a
/// replay sets the same whatever the interface.
pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut TextObject) -> Result<()> {
use vectorcraft_doc::{CharAlign, LeadingModel};
if let Some(j) = str_param(p, "justify") {
t.para.justify = super::typecmd::justify_param(j).ok_or_else(|| bad(cmd, "justify must be auto|left|center|right|justifyAll"))?;
}
let japanese = s.japanese_interface();
let leading = match p.get("leadingModel").filter(|v| !v.is_null()) {
Some(v) => match v.as_str() {
+369
View File
@@ -0,0 +1,369 @@
//! Linked dimensions: generated groups share the existing text/path render and export pipeline.
use super::*;
use serde_json::{Value, json};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"dimension.create",
"Create Dimension",
[],
None,
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
has_doc,
create
),
cmd!(
"dimension.update",
"Update Dimensions",
[],
None,
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
has_doc,
update
),
cmd!(
"dimension.applyToAll",
"Apply to All",
[],
None,
"{id?: dimension id (default the first selected dimension)} gives every dimension in the document that dimension's style (its scale too), as one undo step → {ids}",
has_doc,
apply_to_all
),
]
}
fn error(s: impl ToString) -> EngineError {
EngineError::Other(s.to_string())
}
fn options(s: &Session, p: &Value) -> Result<DimensionOptions> {
let default = s.tool_options_of("dimension");
let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({})));
DimensionOptions::from_params(value).map_err(error)
}
fn point(p: &Value, key: &str) -> Result<Point> {
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?;
let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
Ok(Point::new(x, y))
}
fn distance(p: &Value, fallback: f64) -> Result<f64> {
match p.get("offset") {
Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")),
None => Ok(fallback),
}
}
fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
let mode: DimensionMode =
p.get("mode").map(|v| serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension mode"))).transpose()?.unwrap_or_default();
let source: Option<DimensionSource> = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
if let Some(source) = &source {
let doc = &s.doc()?.doc;
for id in source.nodes() {
if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) {
return Err(error("Choose original artwork, not a dimension's generated paths"));
}
}
let compatible = matches!(
(mode, source),
(DimensionMode::Linear, DimensionSource::Segment { .. })
| (DimensionMode::Angular, DimensionSource::Angle { .. })
| (DimensionMode::Radius | DimensionMode::Diameter, DimensionSource::Circle { .. } | DimensionSource::Arc { .. })
| (DimensionMode::Linear | DimensionMode::Angular, DimensionSource::Points { .. })
);
if !compatible {
return Err(error("Dimension mode does not match its source"));
}
}
let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
let vertex = if mode == DimensionMode::Angular && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
point(p, "vertex")?
} else {
Point::ZERO
};
let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
d.resolve(&s.doc()?.doc).map_err(error)?;
Ok(d)
}
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
let id = doc.alloc_id();
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false };
doc.dimensions = true;
group.extras_mut().dimension = Some(Box::new(dim));
doc.insert(parent, usize::MAX, group)?;
Ok(id)
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let dim = recipe(s, p)?;
let label = dim.label().map_err(error)?;
let parent = s.doc()?.insertion_parent();
s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?);
let id = s.edit("Create Dimension", |doc, sel| {
let id = insert(doc, parent, dim)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({"id":id.0,"label":label}))
}
fn dimension_of(doc: &Document, id: NodeId) -> Result<Dimension> {
doc.node(id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimensions"))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = match p.get("ids") {
Some(v) => serde_json::from_value(v.clone()).map_err(error)?,
None => s.doc()?.selection.objects.clone(),
};
if ids.is_empty() || ids.len() > 10_000 {
return Err(error("Select dimensions to update"));
}
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
s.edit("Update Dimensions", |doc, _| {
for id in &ids {
if !doc.is_editable(*id) {
return Err(error("Dimension is locked or hidden"));
}
let mut dim = dimension_of(doc, *id)?;
if let Some(o) = &o {
dim.options = o.clone();
}
dim.offset = distance(p, dim.offset)?;
dim.resolve(doc).map_err(error)?;
replace(doc, *id, dim)?;
}
Ok(())
})?;
Ok(json!({"ids":ids}))
}
fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let from = match p.get("id") {
Some(v) => NodeId(v.as_u64().ok_or_else(|| error("id must be a dimension id"))?),
None => *st
.selection
.objects
.iter()
.find(|id| st.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some()))
.ok_or_else(|| error("Select a dimension first"))?,
};
let o = dimension_of(&st.doc, from)?.options;
let mut ids = vec![];
st.doc.walk(|n| {
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
ids.push(n.id);
}
});
s.edit("Apply to All", |doc, _| {
for id in &ids {
let mut dim = dimension_of(doc, *id)?;
dim.options = o.clone();
dim.resolve(doc).map_err(error)?;
replace(doc, *id, dim)?;
}
Ok(())
})?;
Ok(json!({"ids":ids}))
}
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
let children = artwork(doc, &dim, &old)?;
if let Some(n) = doc.node_mut(id) {
n.kind = NodeKind::Group { children, clip: false };
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
n.extras_mut().dimension = Some(Box::new(dim));
}
Ok(())
}
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
/// measure. Returns at once in documents without dimensions.
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
if !doc.dimensions {
return Ok(());
}
let mut dimensions = Vec::new();
doc.walk(|n| {
if let Some(d) = &n.extras().dimension {
dimensions.push((n.id, (**d).clone()));
}
});
doc.dimensions = !dimensions.is_empty();
for (id, old) in dimensions {
let mut next = old.clone();
let moved = next.moved.take();
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
// A deleted/degenerate source has no valid measurement. Remove its annotation in
// this same undo step instead of keeping a silently wrong number.
doc.remove(id)?;
continue;
}
// Moved on its own (the art it measures stayed put): it keeps its new place.
if let Some(v) = moved
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
{
next.place(next.handle() + v);
if next.resolve(doc).is_err() {
next = old.clone();
next.moved = None;
}
}
if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
replace(doc, id, next)?;
}
}
Ok(())
}
fn paint(c: [u8; 3]) -> Paint {
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
}
fn line(a: Point, b: Point) -> BezPath {
let mut p = BezPath::new();
p.move_to(a);
p.line_to(b);
p
}
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
let (color, width, kind) =
if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) };
let mut a = Appearance::basic(Paint::None, paint(color), width);
if let Some(st) = a.stroke_mut() {
st.dash = match kind {
LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
LineType::Dotted => {
st.cap = vectorcraft_doc::LineCap::Round;
Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
}
LineType::Solid => None,
};
if arrows {
let head = match o.arrow_style {
ArrowStyle::Filled => Some(Arrowhead::Triangle),
ArrowStyle::Open => Some(Arrowhead::TriangleOpen),
ArrowStyle::Tick => Some(Arrowhead::Slash),
ArrowStyle::None => None,
};
st.start_arrow = head;
st.end_arrow = head;
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
}
}
a
}
/// How far along the line (0 start, 0.5 middle, 1 end) the label sits, kept clear of the arrows.
fn along(o: &DimensionOptions) -> f64 {
match o.text_alignment {
TextAlignment::Left => 0.2,
TextAlignment::Center => 0.5,
TextAlignment::Right => 0.8,
}
}
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<Node>>> {
d.validate().map_err(error)?;
let o = &d.options;
let label = d.label().map_err(error)?;
let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
let mut angle = 0.0;
let text_at;
// Away from the object: past the dimension line; between: on the object's side of it.
let away = o.text_position == TextPosition::Away;
if d.mode == DimensionMode::Angular {
let start = (d.a - d.vertex).atan2();
let sweep = d.angle();
let r = d.offset.abs().max(12.0);
let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r;
let mut arc = BezPath::new();
arc.move_to(on(0.0));
for i in 1..=64 {
arc.line_to(on(i as f64 / 64.0));
}
paths.push((arc, appearance(o, false, true)));
if !o.hide_extensions {
for p in [d.a, d.b] {
let dir = (p - d.vertex) / (p - d.vertex).hypot();
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
}
}
let dir = Vec2::from_angle(start + sweep * along(o));
let gap = o.text_offset + o.font_size * 0.5;
text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) };
} else {
let delta = d.b - d.a;
let u = delta / delta.hypot();
let n = Vec2::new(-u.y, u.x);
let (a, b) = match d.mode {
DimensionMode::Radius => (d.a, d.b),
DimensionMode::Diameter => (d.a - delta, d.b),
_ => (d.a + n * d.offset, d.b + n * d.offset),
};
// The side of the line facing away from the object.
let outward = match d.mode {
DimensionMode::Linear => {
if d.offset < 0.0 {
-n
} else {
n
}
}
_ => n,
};
if d.mode == DimensionMode::Linear && !o.hide_extensions {
for (p, q) in [(d.a, a), (d.b, b)] {
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
}
}
angle = u.atan2();
if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
angle += std::f64::consts::PI;
}
let shift = o.text_offset + o.font_size * 0.5;
text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
paths.push((line(a, b), appearance(o, false, true)));
}
let style = CharStyle {
font_family: o.font_family.clone(),
font_style: o.font_style.clone(),
size: o.font_size,
fill: paint(o.text_color),
..Default::default()
};
let mut text = TextObject::point(Point::ZERO, &label, style);
text.para.justify = vectorcraft_doc::Justify::Center;
super::typecmd::refresh_bounds(&mut text);
// Centre the label's box on its anchor point, along the line.
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
let middle = Affine::translate(-bounds.center().to_vec2());
text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle) * middle;
let mut children = Vec::new();
for (path, a) in paths {
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a)));
}
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
let mut node = Node::new(id, NodeKind::Text(Box::new(text)));
node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into();
children.push(Arc::new(node));
Ok(children)
}
+80 -11
View File
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
use super::edit::{duplicate_in, selected_roots};
use super::edit::{duplicate_in, outermost, selected_roots};
use super::*;
use crate::EngineError;
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Width Point",
[],
None,
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (with index: the nearest points either side change their widths in proportion)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed",
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (the nearest points either side change their widths in proportion, using the sampled widths for a new point)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed",
has_doc,
width_point_set
),
@@ -257,7 +257,22 @@ fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below:
/// points at other positions either side change in proportion (by the same amount from zero).
fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) {
let t = points[i].0;
let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0);
let scale = |v: f64, o: f64, n: f64| {
let width = if o > 1e-9 {
let product = v * n;
if product.is_finite() {
// Keep the existing rounding, including small products that n/o would lose.
product / o
} else {
// With finite nonnegative factors, an overflowing product has both factors
// above one. Divide the larger first so this fallback cannot underflow.
(v.max(n) / o) * v.min(n)
}
} else {
v + n - o
};
width.max(0.0)
};
let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T);
let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T);
for j in before.into_iter().chain(after) {
@@ -293,8 +308,11 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
let st = weighted_stroke(d, id)?;
let before = st.profile.clone();
let half = st.width / 2.0;
let mut prof = st.profile.take().unwrap_or_else(uniform);
let pt = (t, left / half, right / half);
if !half.is_finite() || half <= 0.0 || !pt.1.is_finite() || !pt.2.is_finite() {
return Err(bad(C, "width factors must be finite"));
}
let mut prof = st.profile.take().unwrap_or_else(uniform);
pos = match point_index(p, &prof, C)? {
Some(i) => {
let old = prof.points[i];
@@ -311,10 +329,20 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
None => {
let old = adjust.then(|| prof.at(t));
prof.points.retain(|q| (q.0 - t).abs() > SAME_T);
place(&mut prof.points, pt, true)
let i = place(&mut prof.points, pt, true);
if let Some((left, right)) = old {
adjust_adjoining(&mut prof.points, i, (t, left, right), pt);
}
i
}
};
// Validate on edit_counted's copy: a failed adjoining adjustment must not change
// the document, its dirty state or either side of its history.
if prof.points.iter().any(|p| !p.1.is_finite() || !p.2.is_finite()) {
return Err(bad(C, "width factors must be finite"));
}
st.profile = Some(prof);
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
})?;
@@ -678,7 +706,8 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
let ids =
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
@@ -818,7 +847,7 @@ fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => Ok(v),
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
None => selected_roots(s),
}
}
@@ -908,9 +937,18 @@ fn beyond_horizon() -> EngineError {
EngineError::Other("the pointer is beyond the plane's horizon".into())
}
/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments
/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]).
fn stores_attachments(d: &Document) -> bool {
!PerspectiveGrid::current(d).attached.is_empty()
}
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
/// to `fallback` first. → the edited objects and where the first one was (plane, depth). In an
/// interaction whose document is still its snapshot, the duplicates are the copies the interaction
/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its
/// own ([`stores_attachments`]).
fn persp_edit(
s: &mut Session,
label: &str,
@@ -923,9 +961,20 @@ fn persp_edit(
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None };
s.edit(label, |d, sel| {
g.adopt_stored_attachments(d);
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
let targets = match &kept {
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
// below maps only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?,
None => ids.to_vec(),
};
let mut first = None;
for id in &targets {
let (plane, depth) = match g.attachment_of(d, *id) {
@@ -1111,12 +1160,32 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
}
// In an interaction whose document is still its snapshot, the copies of the objects on the
// plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid
// lists attachments of its own (`stores_attachments`).
let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) {
let on = on_plane(&s.doc()?.doc);
super::object::preview_copies(s, &on, false)?
} else {
None
};
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
let on = on_plane(d);
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
ids = match &kept {
// The document becomes the snapshot with the kept copies, not yet moved, and the
// loop below moves only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None => {
let on = on_plane(d);
if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }
}
};
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
warp_checked(d, *id, &m)?;
Loaded 100 of 648 files, more files were not shown because too many files have changed in this diff. Show more