Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 3cfa330a97 security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:07:20 -04:00
BFlat e99dea21ff Merge pull request #3280 from gorkematlyn/upstream-ui6
UI.6: dock panels into any dock group
2026-10-22 03:47:30 +07:00
BFlat 85d5b10350 Merge pull request #3279 from gorkematlyn/upstream-ui7
UI.7: workspaces keep their layout changes
2026-10-22 03:47:14 +07:00
bflatastic 9cda8452ab UI.6: dock panels into any dock group
Squashed from #3280 onto main.
2026-10-22 03:40:36 +07:00
gorkematlynandClaude Opus 5.5 d6d91c0954 UI.7: match workspace names whatever their case
find_saved matched names case-insensitively but save_as, remember and
switch compared them exactly: "Mine" saved over "mine" made a second
workspace, and changes kept as "Mine" didn't come back when switching
to "mine". save_as now replaces a user workspace of the name in any
case, remembered layouts are kept under the saved spelling and looked
up whatever the case, and sanitized maps remembered layouts to the
saved spelling and keeps one per workspace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
2026-10-22 03:40:18 +07:00
gorkematlynandClaude Opus 5.5 e3e761fe32 UI.7: doc comments on the workspace tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
2026-10-22 03:40:18 +07:00
gorkematlynandClaude Opus 5.5 81de0f6fbf UI.7: keep the newest remembered layout when the list is full
remember() pushed the layout being left and then truncated the list to
64, so with 64 workspaces already remembered it dropped the entry it
had just pushed and the changes were lost. Drop the oldest entries
instead; a test fills the list and checks the last one left survives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
2026-10-22 03:40:18 +07:00
gorkematlynandClaude Opus 5.5 4308821285 UI.7: workspaces keep their layout changes
A workspace remembers the changes made in it: switching away keeps them
(UiState::workspace_changes, kept with the preferences), switching back
restores them, and Reset <name> returns to the saved layout. Saving a
workspace starts it with no changes; renaming keeps them, deleting drops
them, and deleting the current workspace shows Essentials as it was
left. Saved preferences recover a damaged remembered layout instead of
losing the whole file.

Split from #2965 as a reviewer asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
2026-10-22 03:40:18 +07:00
BFlat 9471f92135 Merge pull request #3466 from mockydevs/fix/eyedropper-selected-text
Fix Eyedropper styling for highlighted text ranges
2026-10-22 03:12:19 +07:00
BFlat 1c9edd9cca Merge pull request #2526 from vangel76/feat/2523-shape-builder-gap-detection
Shape Builder: Gap Detection and Tool Options (#2523)
2026-10-22 03:03:45 +07:00
Don Omondi a3b2b18cb0 M3: preserve highlighted Eyedropper ranges and reject incompatible transparency 2026-10-22 02:58:48 +07:00
Dylan M. Taylor 0f7156169b ci: add sonar-project.properties to use CI-based analysis
Presence of this file marks the project ineligible for SonarCloud Automatic
Analysis, resolving the CI-vs-automatic-analysis conflict.
2026-10-21 15:56:13 -04:00
bflatastic 10a178f64a Shape Builder: Gap Detection and Tool Options (#2523)
Squashed from #2526 onto main.
2026-10-22 02:53:17 +07:00
Dylan M. Taylor a4c21343bb ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:07 -04:00
BFlat 8a48680ad2 Merge pull request #3457 from nrq6993-a11y/stream/vc-issue-3446-expand-stroke-round-caps
Expand stroke: compact plain stroke spines before outlining
2026-10-22 02:31:43 +07:00
bflatastic dd2f5da98c Expand stroke: compact plain stroke spines before outlining
Squashed from #3457 onto main.
2026-10-22 02:21:59 +07:00
BFlat 19c7d61f8a Merge pull request #3460 from dotCooCoo/fix/live-paint-tools-skip-hidden-faces
Live Paint: the Live Paint tools and livePaint.fill skip hidden faces
2026-10-22 02:12:20 +07:00
BFlat 9effb4e44e Merge pull request #2520 from vangel76/fix/2170-recolor-model
Recolor: recolor.apply takes a model option for the new colour's model (#2170)
2026-10-22 02:12:09 +07:00
dotCooCoo 4bd55029e8 Live Paint: the Live Paint tools and livePaint.fill skip hidden faces
A face hidden on its own (for example with Object > Hide after Live
Paint Selection picks it) while its Live Paint group stays visible was
still highlighted, selected by Live Paint Selection and filled by the
Live Paint Bucket, and it kept the face of a group behind it from being
picked.

live_paint_at and lp_part_at now take the front-most face that is not
hidden (visible_face_at). A point on a hidden face falls through to the
visible face of a group behind it; where there is none, an edge pick
takes the nearest visible edge as it does outside the faces.
livePaint.fill also skips hidden faces. With group, a point on a hidden
face is an error. With ids, it fills the face there of another Live
Paint group among the objects, or else fails and does not merge the
group.

Docs: docs/mcp.md.
2026-10-22 02:01:58 +07:00
bflatastic 62776d5f46 Recolor: recolor.apply takes a model option for the new colour's model (#2170)
Squashed from #2520 onto main.
2026-10-22 02:01:57 +07:00
BFlat 44104b67ca Merge pull request #3456 from nrq6993-a11y/stream/vc-featD-g4-snap-to-glyph-parity
G4: Mark Snap to Glyph done in parity docs and test the menu toggle
2026-10-22 01:35:37 +07:00
BFlat e2d75c7ba6 Merge pull request #3174 from KaboomFox/p8-filter-effects
Plug-ins: filters read and set effects and stroke styles (G17)
2026-10-22 01:35:21 +07:00
Cursor Agentandnrq6993-a11y 59f4b753dd G4: mark Snap to Glyph done in parity docs and test the menu toggle
## Summary
Aligns parity checklists with shipped View › Snap to Glyph behaviour (#3124):
when Smart Guides are on, drawing tools snap to live-text baselines and glyph
bounds via `view.snapToGlyph`. Adds a headless test that `view.snapToGlyph`
toggles the UI view flag like Illustrator’s menu item.

## Tests
- `vectorcraft-ui-egui`: `snap_to_glyph_menu_command_toggles_the_view_flag`
- Existing `tools::text_tests::snap_to_glyph_lines_up_new_point_type_with_a_baseline`

Part of gap G4

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 01:25:51 +07:00
Louise FoxandClaude Opus 5.5 8bc117eb8b Plug-ins: doc comments for the functions the filter changes touch
The docstring check of the review counts the functions a pull request
touches: effect.apply, effect.setParams, plugin.run, the line cap
name, the reader of an output object and seven of the object tests had
no doc comment. Each now says what it does or checks; the join and cap
names get one each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-22 01:25:51 +07:00
Louise FoxandClaude Opus 5.5 78105cc695 Plug-ins: filters read and set effects and stroke styles (G17)
A filter could reshape and repaint objects, but not set the stroke's
join, cap or miter limit, nor the effects an object's appearance holds.
A tool that prepares objects for a live effect (a sheet of samples,
each with the effect at its own settings) had to leave the user to
apply the effect to each object by hand.

- Each input stroke has its join, cap and miterLimit beside its paint
  and width (live effects get the same, through the shared encoder),
  and each object its own effect records, {id, params, visible}.
- An output stroke may set them (miter limit clamped to 1-500), and an
  output object's "effects" replaces its own effects.
- The host admits each record as effect.apply does: an effect that
  effect.list lists or an installed live-effect plug-in, parameters it
  takes (filled in to their defaults; a plug-in's checked against its
  manifest), one 3D effect that fits the object (its fills' and
  strokes' own effects counted), and the texture of an effect with
  Load Texture (Glass, Texturizer) as the effect stores it, never a
  file to read. A record the object already had stays as
  it is, so a filter that hands its input back changes nothing. One
  refused record refuses the whole output and the document stays as
  it was.

The plug-ins crate checks the records' form; which effects exist is the
engine's to say (objects::AdmitEffects, engine plugin::admit_effects).
effects::validate_three_d is the one check of whether a 3D effect fits
an object, for effect.apply, effect.setParams and the filters.

A path handed back as it was now stays the live shape it was: write()
dropped a live rectangle's or ellipse's parameters whenever the output
held a path, changed or not, so a filter that returned its input
turned a live rectangle into a plain path.

objects/tests.rs checks the round trip, stroke styles on existing and
new strokes, the host's refusal, the bad forms refused, and a live
rectangle handed back unchanged (a plain path before the fix);
tests_plugins.rs runs filters that set a built-in and a plug-in effect,
an echo that changes nothing, and six refused outputs.
CRAFT_PLUGIN_FRAME_WASM=<folder> saves a test filter to try in the app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-22 01:25:50 +07:00
BFlat ff021918a6 Merge pull request #3096 from nrq6993-a11y/stream/vc-feat3-g2-artistic-fresco
G2: Effect › Artistic › Fresco raster filter
2026-10-22 01:25:22 +07:00
BFlat 45f806d307 Merge pull request #3010 from nrq6993-a11y/stream/vc-feat1-g2-sketch-torn-edges
G2: Effect › Sketch › Torn Edges raster filter
2026-10-22 01:25:06 +07:00
BFlat fe71ee2d75 Merge pull request #3399 from nrq6993-a11y/stream/vc-featD-manual-tate-chu-yoko
Character panel Tate-chu-yoko marks vertical type as a horizontal block
2026-10-22 01:24:50 +07:00
bflatastic 74ec2751ed Docs: Photoshop filter count after Fresco and Torn Edges (54/57), one row each 2026-10-22 01:15:10 +07:00
bflatastic eb9e064254 G2: Effect › Artistic › Fresco raster filter
Squashed from #3096 onto main.
2026-10-22 01:15:09 +07:00
bflatastic 5925e6339a G2: Effect › Sketch › Torn Edges raster filter
Squashed from #3010 onto main.
2026-10-22 01:06:25 +07:00
bflatastic b45bb3dd54 Character panel Tate-chu-yoko marks vertical type as a horizontal block
Squashed from #3399 onto main.
2026-10-22 01:06:24 +07:00
BFlat 875f98d211 Merge pull request #3453 from lehni/update/canvas-upload
Canvas: each update copies the rendered frame whole instead of pixel by pixel
2026-10-22 01:06:01 +07:00
BFlat c21ac5ca02 Merge pull request #3452 from nrq6993-a11y/stream/vc-feat-3343-pmd-unsupported
3343: recognize PageMaker .pmd on open with a clear message
2026-10-22 01:05:39 +07:00
BFlat 3987fdfca7 Merge pull request #3063 from nrq6993-a11y/stream/vc-feat1-g2-pathfinder-soft-mix
G2: Effect › Pathfinder › Soft Mix blends overlapping CMYK fills
2026-10-22 01:05:23 +07:00
BFlat 9439087946 Merge pull request #3412 from nrq6993-a11y/stream/vc-featD-g4-system-layout
G4: System Layout spaces every text object in whole points
2026-10-22 01:05:06 +07:00
Jürg Lehni a92f0264c2 Canvas: each update copies the rendered frame whole instead of pixel by pixel
The renderer's pixels and egui's are both premultiplied RGBA8, so color_image copies the bytes in one go. At 3456×2234 that takes 0.9 ms instead of 3.4 ms per update, at 5120×2880 1.6 ms instead of 6.8 ms. Every rendered image the UI shows (canvas, thumbnails, previews) goes through it.
2026-10-22 00:54:44 +07:00
Cursor Agentandnrq6993-a11y 1824829931 #3343: recognize PageMaker .pmd on open with a clear message
## Summary
Opening or placing a .pmd file now fails with a dedicated unsupported-format
hint (like DWG and PICT), telling users to export PDF or EPS from PageMaker
instead of a generic unknown-format error. document.formats lists PageMaker
in its unsupported table.

## Tests
- formats_list_dxf_and_what_can_not_be_written
- dwg_and_pict_say_what_to_use_instead (includes brochure.PMD open)

Part of storytold/vectorcraft#3343

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 00:54:43 +07:00
bflatastic a5c0c29b25 G2: Effect › Pathfinder › Soft Mix blends overlapping CMYK fills
Squashed from #3063 onto main.
2026-10-22 00:54:43 +07:00
bflatastic adbe0566a6 G4: System Layout spaces every text object in whole points
Squashed from #3412 onto main.
2026-10-22 00:54:43 +07:00
BFlat 675b759a37 Merge pull request #2708 from dotCooCoo/fix/i18n-scan-reads-bad-messages
i18n: the message scan reads errors made with bad() and error variants, and new ones need rows
2026-10-22 00:51:36 +07:00
BFlat d58888a95c Merge pull request #3028 from nrq6993-a11y/stream/vc-feat3-g2-artistic-colored-pencil
G2: Effect › Artistic › Colored Pencil raster filter
2026-10-22 00:51:06 +07:00
BFlat e0bef14664 Merge pull request #3415 from lehni/fix/path-type-rtl-overflow
Type on a path: right-to-left text that overflows shows its start
2026-10-22 00:50:49 +07:00
dotCooCoo a3e7dc9e41 i18n: the message scan reads errors made with bad() and error variants, and new ones need rows
complete_languages_translate_every_message read a message literal only
right after a marker such as Err( or Other(. It did not read the
message given to the engine's bad(cmd, ...), so this test did not
require a row for a parameter error. It also did not read the messages
given to the engine's err() and error(), to the PDF writer's bad() and
failed(), or to an error enum's variant such as PsError::Limit(...).
1,122 of these messages have no row in any complete catalog.

The scan now reads those messages, a literal or a format! string, in
the same template form as the catalogs' rows. It skips PsError::Ps and
EngineError::UnknownCommand, whose first argument is a PostScript error
name or a command id. The 1,122 messages are listed in
untranslated_messages.txt next to the tests, together with the 71 that
pull requests open at 009384b add without rows, and the test lets a
listed message through whether or not it has rows. Every other message
needs a row in each complete catalog, so a new message made this way
fails the test until it has its rows.
VECTORCRAFT_I18N_DUMP_UNTRANSLATED=<file> writes the messages in the
sources that lack a row. No catalog rows change.

Docs: docs/development.md, docs/localization-parity.md.
2026-10-22 00:40:10 +07:00
BFlat f381513cbe Merge pull request #3044 from nrq6993-a11y/stream/vc-feat3-g2-artistic-rough-pastels
G2: Effect › Artistic › Rough Pastels raster filter
2026-10-22 00:38:27 +07:00
BFlat f5011c7454 Merge pull request #3035 from nrq6993-a11y/stream/vc-feat3-g2-artistic-underpainting
G2: Effect › Artistic › Underpainting raster filter
2026-10-22 00:38:11 +07:00
BFlat ab9b054164 Merge pull request #3032 from nrq6993-a11y/stream/vc-feat3-g2-artistic-paint-daubs
G2: Effect › Artistic › Paint Daubs raster filter
2026-10-22 00:37:54 +07:00
BFlat cb84ffcfa1 Merge pull request #3038 from nrq6993-a11y/stream/vc-feat3-g2-artistic-palette-knife
G2: Effect › Artistic › Palette Knife raster filter
2026-10-22 00:37:37 +07:00
bflatastic 0c9a7a89a0 Docs: Photoshop filter count after Colored Pencil (52/57), one Artistic row 2026-10-22 00:31:30 +07:00
bflatastic 03a2da13ba G2: Effect › Artistic › Colored Pencil raster filter
Squashed from #3028 onto main.
2026-10-22 00:30:39 +07:00
Jürg Lehni 83a5978d6e Type on a path: right-to-left text that overflows shows its start
Its glyphs were put in visual order and then cut where the path ran out, which kept the text's end. Now the longest start of the text, in reading order, whose glyphs fit in visual order is laid out, as area type does with each line it breaks, by the same rules that place the line: steps where the glyphs sit, hanging punctuation and the path beyond the brackets.
2026-10-22 00:30:08 +07:00
BFlat c6677df848 Merge pull request #2853 from dotCooCoo/feat/load-variable-library
Variables: Load Variable Library… reads a variable library's text variables and data sets
2026-10-22 00:28:05 +07:00
BFlat d783e1565c Merge pull request #3431 from addibble/feat/ui-egui-bundled-fonts-feature
Text: an embedder of the egui panels may leave the bundled fonts out
2026-10-22 00:27:48 +07:00
BFlat 56235323a1 Merge pull request #3417 from KnottyDyes/squad/pdf-import-tracked-text
PDF import: letter-spaced text keeps its word spaces
2026-10-22 00:27:36 +07:00
BFlat 322b4852e2 Merge pull request #3428 from dotCooCoo/fix/commands-refuse-booleans-and-numbers-as-text
Commands: a boolean or number given as text is refused
2026-10-22 00:27:17 +07:00
BFlat ebce8f12df Merge pull request #3448 from midasdf/jp/ruby
Type: ruby (ふりがな) for live text
2026-10-22 00:26:59 +07:00
BFlat 76b6d31646 Merge pull request #3442 from alpha-rock/feature/3d-light-presets
Add lighting presets to 3D effects
2026-10-22 00:26:40 +07:00
BFlat 52a94d5a84 Merge pull request #3445 from dotCooCoo/feat/docinfo-repeat-objects
Document Info: a Repeat Objects category lists the document's repeats
2026-10-22 00:26:22 +07:00
BFlat 4408e9fedb Merge pull request #3430 from addibble/feat/properties-clip-group-quick-actions
G1: Properties: Edit Clipping Path and Edit Contents for one clip group
2026-10-22 00:26:05 +07:00
BFlat 0756fb0948 Merge pull request #2970 from nrq6993-a11y/stream/vc-issue-2178-pathfinder-ungroup
Fix #2178: Pathfinder Unite groups disconnected islands for Ungroup
2026-10-22 00:25:33 +07:00
bflatastic 3c6d2b371e Docs: Photoshop filter count after the Artistic batch (51/57) 2026-10-22 00:23:51 +07:00
bflatastic 695f7c0d4b G2: Effect › Artistic › Underpainting raster filter
Squashed from #3035 onto main.
2026-10-22 00:23:50 +07:00
bflatastic 755f2b9158 G2: Effect › Artistic › Rough Pastels raster filter
Squashed from #3044 onto main.
2026-10-22 00:23:50 +07:00
bflatastic 03bd5e5a53 G2: Effect › Artistic › Paint Daubs raster filter
Squashed from #3032 onto main.
2026-10-22 00:23:49 +07:00
bflatastic f329f6271d G2: Effect › Artistic › Palette Knife raster filter
Squashed from #3038 onto main.
2026-10-22 00:23:48 +07:00
bflatastic bbe026607f Variables: Load Variable Library… reads a variable library's text variables and data sets
Squashed from #2853 onto main.
2026-10-22 00:12:39 +07:00
Drew DibbleandClaude Opus 5.5 e065467a65 xtask: doc comments for the functions the fonts check touches
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 00:10:57 +07:00
KnottyDyesandClaude Opus 5.5 73cbba8b8d PDF import: even gaps alone still read as word spaces
Glyphs placed one by one with even gaps could be letters set apart or
words of one letter each, and a file's positions can't tell them apart.
The spacing a line's letters share is now taken off its gaps only when
the line also shows where its words break: with spaces it draws (as
with Tc), or with gaps wider than the rest (tracked words placed glyph
by glyph). Otherwise every gap past a fifth of an em is a space, as
before, so A, B, C and D placed with ordinary word spaces between them
still come in as "A B C D".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:10:57 +07:00
bflatastic 609c98f458 Docs: one row per area after the batch 2026-10-22 00:10:57 +07:00
KnottyDyes b024756e9f PDF import: wide even gaps and right-to-left lines keep their spaces
A line whose shared gap is over half an em is no longer read as letter
spaced: capping that gap at half an em left glyphs set 0.55 em apart
with no spaces. The regression now covers 0.55, 0.75 and 1 em.

Whether a line is right to left is now known before its gaps are read,
from its logical order: the paragraph direction is only set afterwards,
so right-to-left lines were read for letter spacing too.

The revision-history row moves out of the Headline table, where it had
been put by mistake.

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

# Conflicts:
#	docs/target-app-parity.md
2026-10-22 00:10:57 +07:00
bflatastic e7cdf154ca Commands: a boolean or number given as text is refused
Squashed from #3428 onto main.
2026-10-22 00:10:57 +07:00
bflatastic 14f804cdee Type: ruby (ふりがな) for live text
Squashed from #3448 onto main.
2026-10-22 00:10:56 +07:00
bflatastic 2cd7f156fb Add lighting presets to 3D effects
Squashed from #3442 onto main.
2026-10-22 00:01:37 +07:00
dotCooCoo 4d1581f3da Document Info: a Repeat Objects category lists the document's repeats
Document Info had no Repeat Objects category, and document.info
refused category "repeats". The reference app's Document Info panel
menu lists Repeat Objects after Font Details.

Document Info now has a Repeat Objects category after Embedded Images.
It lists each radial, grid and mirror Repeat object by name with its
kind and the number of instances it makes. For a grid it gives the
rows and columns instead, followed by the number of instances it
draws when that is fewer. The count comes from the new
RepeatSpec::instances, which gives the number of instances a repeat
draws. That number is smaller than its options give when repeats
nested in its source would pass the instance budget. With Selection
Only the category lists the Repeat objects in the selection.
document.info gives them as repeatObjects, the panel's flyout offers
the category, and the text report that Document Info › Save… and
File › Package write has a REPEAT OBJECTS section. The title has a row
in every catalog.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 00:01:36 +07:00
Drew DibbleandClaude Opus 5.5 4f5f573ded Properties: doc comments for the panel's entry point and its test helpers
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 00:01:36 +07:00
nrq6993-a11yandCursor 7ceaf900d6 Pathfinder Unite: compound path, not group (#2178)
Illustrator documents Unite as a single compound path; use shape_node_styled and compoundPath.release in tests instead of wrapping islands in groups.

Co-authored-by: Cursor <[email protected]>
2026-10-22 00:01:36 +07:00
Drew DibbleandClaude Opus 5.5 f19b33c219 G1: Properties: Edit Clipping Path and Edit Contents for one clip group
With one clip group selected, the Properties panel has Edit Clipping
Path and Edit Contents at its top, under the Clip Group heading, as the
reference app does: "With the clip group selected, click the Edit
Clipping Path button at the top of the Properties panel to select and
edit the mask, or click the Edit Contents button to select and edit the
masked content"
(https://www.adobe.com/learn/illustrator/web/crop-using-masks). They
run the existing object.clippingMask.editMask and editContents, which
select the clipping path or the clipped art for the selection tools, as
Object > Clipping Mask does. Edit Contents shows only when the group
clips something. With several objects selected they don't show (the
tutorial describes one selected clip group); the Contextual Task Bar
does the same. Both labels are already in every catalog.

Test: panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top
(first under the heading, each selecting its part; Edit Clipping Path
alone once the clipped art is deleted; neither for a clip group
selected with another object in either order, or for a plain group).
Documented in docs/target-app-parity.md, docs/ui-parity.md and
docs/gaps.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 00:01:36 +07:00
nrq6993-a11yandCursor 0650c24a09 Fix #2178: Pathfinder Unite groups disconnected islands for Ungroup
After Unite, split the result with draw2::components so each island (outer
contour plus its holes) becomes one group child instead of one compound path.

Co-authored-by: Cursor <[email protected]>
2026-10-22 00:01:36 +07:00
BFlat 45025a8d53 Merge pull request #3292 from dotCooCoo/fix/command-integers-refuse-values-past-usize
Commands: an index of 2^32 or more no longer wraps around on 32-bit builds
2026-10-22 00:01:15 +07:00
BFlat 6f711b94d5 Merge pull request #3139 from nrq6993-a11y/stream/vc-feat1-g12-touch-workspace-pref
G12: Honor Preferences › Devices › Enable Touch Workspace
2026-10-22 00:00:36 +07:00
BFlat be669cbfc5 Merge pull request #3136 from nrq6993-a11y/stream/vc-feat1-g4-mixed-paragraph-leading
G4: Paragraph panel indents and spacing show blank when paragraphs differ
2026-10-22 00:00:20 +07:00
BFlat 240af4613d Merge pull request #3101 from nrq6993-a11y/stream/vc-feat1-g4-type-scale-text-attributes
G4: Scale Text Attributes bakes object scale into type sizes
2026-10-22 00:00:00 +07:00
BFlat 1541de79ca Merge pull request #3091 from nrq6993-a11y/stream/vc-feat1-g1-live-corners-relative-rounding
G1: Live Corners relative corner rounding (#1144)
2026-10-21 23:59:45 +07:00
BFlat 924d762640 Merge pull request #3075 from nrq6993-a11y/stream/vc-feat3-g5-key-color-eyedropper
G5: Pattern brush Key Color eyedropper in Brush Options
2026-10-21 23:59:29 +07:00
BFlat 9c278e0688 Merge pull request #3070 from nrq6993-a11y/stream/vc-feat3-g2-texturizer-load-texture
G2: Texturizer Load Texture in effect dialog
2026-10-21 23:59:12 +07:00
BFlat 154f09400e Merge pull request #3061 from nrq6993-a11y/stream/vc-feat1-g2-pathfinder-hard-mix
G2: Effect › Pathfinder › Hard Mix live effect
2026-10-21 23:58:57 +07:00
BFlat 730ccfcf8b Merge pull request #3053 from nrq6993-a11y/stream/vc-feat1-g1-cmd-alt-group-selection
G1: Cmd+Alt temporarily lends Group Selection
2026-10-21 23:58:41 +07:00
BFlat 773964cb80 Merge pull request #3026 from nrq6993-a11y/stream/vc-feat-g2-artistic-dry-brush
G2: Effect › Artistic › Dry Brush raster filter
2026-10-21 23:58:26 +07:00
BFlat a97c92481c Merge pull request #3000 from nrq6993-a11y/stream/vc-issue-2995-panel-tab-reorder
Fix #2995: reorder grouped panel tabs by dragging within the tab strip
2026-10-21 23:58:09 +07:00
BFlat e5be5c21d0 Merge pull request #2592 from dotCooCoo/feat/lock-artboards
Artboards: the Artboards panel locks an artboard and the art on it
2026-10-21 23:57:52 +07:00
BFlat f85fe0effb Merge pull request #3317 from dotCooCoo/feat/align-buttons-in-keyboard-shortcuts
Keyboard Shortcuts: each Align and Distribute button of the Align panel can be given a key
2026-10-21 23:57:37 +07:00
BFlat 775016b455 Merge pull request #3312 from dotCooCoo/feat/bristle-brush-name-limit
Brushes: New Brush and Bristle Brush Options take a bristle brush name of at most 31 characters
2026-10-21 23:57:22 +07:00
BFlat 29d783eac1 Merge pull request #3434 from dotCooCoo/feat/docx-kashida-justification
Place: Word's low, medium and high kashida justification sets Insert Kashidas
2026-10-21 23:57:03 +07:00
BFlat 4f0d98107b Merge pull request #3433 from dotCooCoo/fix/tabs-ruler-sets-whole-points
Tabs: a stop placed or moved on the ruler lands on a whole point
2026-10-21 23:56:47 +07:00
BFlat 844d78e58f Merge pull request #3427 from dotCooCoo/fix/pdf-export-writes-rights-fields
PDF export: PDF and .ai files carry File Info's copyright status, notice and URL
2026-10-21 23:56:19 +07:00
BFlat 49355a08bc Merge pull request #3422 from midasdf/jp/jis-paper-sizes
Print: JIS B4/B5 and Japanese paper sizes (はがき・封筒・四六判)
2026-10-21 23:56:04 +07:00
BFlat cda55f690a Merge pull request #3419 from flamesquirrel/eps-styled-type
EPS/.ai editing data: type keeps stacked art styles as live appearance
2026-10-21 23:55:48 +07:00
BFlat 2a1885d2de Merge pull request #3411 from nrq6993-a11y/stream/vc-featD-g4-fractional-widths
G4: Fractional Widths rounds a type object's advances to whole points
2026-10-21 23:55:05 +07:00
BFlat 10d78516f6 Merge pull request #3409 from lehni/fix/thread-release-duplicates
Threaded text: releasing a frame from a longer thread no longer duplicates its text
2026-10-21 23:54:49 +07:00
BFlat e6db3421ce Merge pull request #3408 from lehni/feature/path-type-threads
Threaded text: type on a path threads, mixed with area type
2026-10-21 23:54:34 +07:00
BFlat 0ad0b7ab0f Merge pull request #3402 from dotCooCoo/feat/brush-colorization-tips
Brush Options: Tips shows each colorization method on a sample
2026-10-21 23:54:17 +07:00
BFlat ab6f62128e Merge pull request #3398 from nrq6993-a11y/stream/vc-featD-g4-no-break
G4: No Break keeps marked characters on one line
2026-10-21 23:54:01 +07:00
BFlat 84efde6ba0 Merge pull request #3394 from dotCooCoo/feat/optical-margin-right-to-left
Type: Optical Margin Alignment hangs punctuation in right-to-left paragraphs
2026-10-21 23:53:45 +07:00
BFlat 833523e29e Merge pull request #3397 from dotCooCoo/feat/f8-new-symbol-key
Symbols: F8 opens Symbol Options for a new symbol from the selection
2026-10-21 23:53:30 +07:00
BFlat c8432aee37 Merge pull request #3390 from dotCooCoo/feat/new-brush-dialog
Brushes: New Brush asks for the type and opens its Brush Options
2026-10-21 23:53:14 +07:00
BFlat cb1512d9fe Merge pull request #3388 from dotCooCoo/fix/dialog-sliders-take-arrow-keys
Dialogs: sliders step their field's number with the arrow keys
2026-10-21 23:52:58 +07:00
BFlat 3b3eba6ae1 Merge pull request #3385 from dotCooCoo/fix/brush-rename-and-delete-reach-every-stroke
Brushes: renaming or deleting a brush updates every stroke that uses it
2026-10-21 23:52:41 +07:00
BFlat 8f2e2740c3 Merge pull request #3374 from dotCooCoo/fix/expand-keeps-random-transform-seeds
Effects: expanded, flattened and exported art keeps the random Transform the canvas drew
2026-10-21 23:52:24 +07:00
BFlat fae70b006c Merge pull request #3369 from dotCooCoo/feat/brush-reorder
Brushes: dragging a brush reorders it within its type
2026-10-21 23:52:08 +07:00
BFlat ef7cdf5927 Merge pull request #3368 from dotCooCoo/feat/library-panel-lists-missing-file
Library panels: a persistent panel whose file is gone stays listed as missing
2026-10-21 23:51:51 +07:00
BFlat 21291ab387 Merge pull request #3438 from dotCooCoo/fix/docs-localization-parity-lists-dutch-danish-indonesian
Docs: localization parity lists the Dutch, Danish and Indonesian catalogs
2026-10-21 23:51:34 +07:00
BFlat 2cf7ad1ad1 Merge pull request #3418 from KnottyDyes/squad/icc-profile-padding
ICC: all built-in profiles are checked for aligned sizes and tags
2026-10-21 23:51:18 +07:00
BFlat de01edb28a Merge pull request #3414 from dotCooCoo/fix/docs-zh-hant-catalog-counts
Docs: localization parity row counts match the catalogs on main
2026-10-21 23:51:05 +07:00
BFlat 0a1c4cdd87 Merge pull request #3406 from oracola91/fix/recovery-folders-owner-only
Data Recovery: the recovery folders are owner-only on Unix
2026-10-21 23:50:49 +07:00
BFlat 5fc175a5ef Merge pull request #3389 from dotCooCoo/fix/tests-data-set-export-fresh-folder
Tests: the data set export test checks the whole file in a folder emptied first (#2969)
2026-10-21 23:50:33 +07:00
BFlat ba047ee5f7 Merge pull request #3382 from dotCooCoo/fix/altgr-typing-runs-no-command
Type: a character typed with AltGr while editing text runs no command
2026-10-21 23:50:18 +07:00
BFlat ecb7ac1fc6 Merge pull request #3376 from dotCooCoo/fix/tests-half-form-face-filter
Tests: the half-form test uses only a fallback face that forms Ka's half form (#2670)
2026-10-21 23:50:02 +07:00
BFlat f9e289410d Merge pull request #3373 from dotCooCoo/fix/docs-pages-sentence-names-the-file
Docs: the document.open pages sentence names the file saved without PDF compatibility
2026-10-21 23:49:47 +07:00
BFlat a1738c15d4 Merge pull request #3372 from dotCooCoo/fix/save-as-replaces-a-typed-extension
Save As: a typed extension of a format the save doesn't write is replaced
2026-10-21 23:49:31 +07:00
BFlat 9a5c5459bd Merge pull request #3371 from nikolainyegaard/language-picker-order
Preferences: list the languages alphabetically by their own name
2026-10-21 23:49:15 +07:00
bflatastic c895f64063 Format: rustfmt after the batch 2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 4e5f5e608b G12: translate Touch workspace for complete i18n catalogs
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor cc4836abb7 G4: reuse Character panel Shared/Metric for mixed paragraph fields.
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 56cfb79d24 G4: add i18n rows for Scale Text Attributes command and error
Complete menu catalogs need plain and @msg entries so i18n tests pass for the new type command.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 240d8d4891 Fix collapsible_if clippy in object.setLiveShape relative radii path.
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
Cursor Agentandnrq6993-a11y b13072b77c M3: rustfmt eyedropper and brush key-color UI
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-21 23:38:03 +07:00
bflatastic e5471be4d9 Commands: an index of 2^32 or more no longer wraps around on 32-bit builds
Squashed from #3292 onto main.
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 1c7b02a6fe G12: honor Enable Touch Workspace preference
Switch to the Touch built-in workspace when the preference is on and a touch
screen is present; restore the prior workspace when it is turned off.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor c7df7a56e8 docs: note G4 Paragraph panel mixed indent fields
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 07a49d447d G4: Scale Text Attributes bakes transform scale into type sizes
After scaling groups, type.scaleTextAttributes matches panel sizes to what draws on canvas without leaving scale in the affine.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor a719b22e1b G1: use "the reference app" in setLiveShape relativeRadii params doc.
cargo xtask brands rejects vendor names in CommandSpec params strings.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 3f45472e15 i18n: add Key Color eyedropper tooltip to complete catalogs
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 45cabc1c3c G4: blank Paragraph panel indents when paragraphs differ
Mixed left/right/first-line indents and space before/after now follow the same shared-value rules as alignment buttons.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 59534c1cbb Fix LiveCorners::style to report stored radii, not fitted draw sizes.
Star corners with a large uniform radius kept None in the panel because fitted draw radii differ per tip; tests and UI expect the value the user set.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 2d85d8818e docs: mark G5 Key Color eyedropper done in gaps.md
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor ab0ddf0eae G1: Live Corners relative rounding (#1144)
Store corner radii as fractions of each corner's max so rounding scales with the shape; expose via Corners dialog and object.setLiveShape relativeRadii.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor 6433b6ca3a G5: Pattern brush Key Color eyedropper in Brush Options
Hue Shift colorization can sample key color from canvas art via the
eyedropper next to the swatches, matching Illustrator's brush workflow.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:03 +07:00
nrq6993-a11yandCursor cdef620083 G2: Share glass_fields for Glass and Texturizer load-texture UI.
Drop duplicate texturizer_fields/texturizer_params per review.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor b3a380cfa0 G2: translate Hard Mix pathfinder menu and undo label
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor 2dcceea8e1 G1: limit Cmd+Alt Group Selection to active Direct Selection
Pen Cmd-borrow keeps Direct Selection when Alt is held so guide copy and other Alt modifiers on the lent tool still work.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
bflatastic 0a3628f62f G2: Effect › Artistic › Dry Brush raster filter
Squashed from #3026 onto main.
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor 571ac32362 Fix floating tab reorder deadlock by calling strip_tab_reorder in-frame.
Route dock and floating strip reorders through shared logic, sync layout.floating order, and assert reorder via window.panel.move in the harness test.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
bflatastic 58066428c6 Artboards: the Artboards panel locks an artboard and the art on it
Squashed from #2592 onto main.
2026-10-21 23:38:02 +07:00
bflatastic 9c20027778 Keyboard Shortcuts: each Align and Distribute button of the Align panel can be given a key
Squashed from #3317 onto main.
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor dfa7bd12d9 G2: Texturizer Load Texture matches Glass and Illustrator.
Users can pick an image in the Texturizer dialog; it is stored as relief heights and survives save/open like Glass textures.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor 3f3888d040 G2: Effect › Pathfinder › Hard Mix mixes CMYK per face
Overlapping faces take the highest of each CMYK channel from every covering shape and drop strokes, matching Illustrator's Hard Mix effect.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor fface52206 G1: Cmd+Alt lends Group Selection for one gesture
When Direct Selection was last used or is active, Cmd+Alt borrows Group Selection for pointer presses, matching the documented modifier chord.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor 951d1705e3 Fix #2995: unify floating tab reorder with window.panel.move
Route floating strip drags through the same command path as the dock strip and centralize group tab index moves in one helper.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor 4cba355f71 Fix #2995: address PR review on tab reorder and dock drag begin
Reuse catalogued window.panel.move errors, keep legacy dock drags from clearing docking on Escape, reorder floating tabs locally without reconcile re-entry, and fix dock_tab_order test setup.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor ba2a5bfaed Fix #2995: route tab reorder through window.panel.move and harden dock_tab_order
Legacy and floating strip drags update order via the panel.move command; partial saved orders append missing tabs, with command-level tests and Illustrator workspace doc link.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
nrq6993-a11yandCursor 2c715c9fe3 Fix #2995: reorder grouped panel tabs by dragging within the tab strip
Horizontal drags on dock and native floating tab strips now reorder tabs
instead of only tearing off; storytold/vectorcraft#2995.

Co-authored-by: Cursor <[email protected]>
2026-10-21 23:38:02 +07:00
bflatastic 5e24e59bd3 Brushes: New Brush and Bristle Brush Options take a bristle brush name of at most 31 characters
Squashed from #3312 onto main.
2026-10-21 23:38:01 +07:00
bflatastic 1cfeaabb2d Docs and format: one row per area after the batch, rustfmt
Rows two PRs of the batch both changed came out twice in localization-parity, target-app-parity and type-parity; the newest of each is kept. prefs_dialog.rs is formatted.
2026-10-21 23:38:01 +07:00
dotCooCoo 3884cf9704 Tabs: a stop placed or moved on the ruler lands on a whole point
A click on the Tabs panel's ruler added a stop, and a drag moved one,
at the fraction of a point under the pointer. The reference app's Tabs
page says that the tab ruler does not set stops in increments smaller
than 1, and that the X or Y field sets them as finely as 0.01 point.
The page does not name the unit of 1. The ruler here uses 1 point, the
unit the page gives for the X or Y field, in every General unit.

With Snap to Unit off and Shift not held, a stop placed or dragged on
the ruler now lands on the nearest whole point. With Snap to Unit on,
or Shift held, it still lands on the ruler's nearest tick, which on a
millimeter ruler is a fraction of a point. A position typed in the X
or Y field keeps its hundredths of a point.

Docs: docs/target-app-parity.md.
2026-10-21 23:32:26 +07:00
dotCooCoo 6807e757af PDF export: PDF and .ai files carry File Info's copyright status, notice and URL
The PDF writer's metadata has no copyright fields, so the XMP packet of
PDF exports and PDF-compatible .ai and .ait saves left out File Info's
copyright status, notice and URL. A PDF exported without its editing
data opened with the status Unknown and an empty notice and URL,
although the reader takes them from dc:rights, xmpRights:WebStatement
and xmpRights:Marked.

The export now adds those three properties to the XMP packet after
the writer has written it, as it does for the modification date.
Marked is True for Copyrighted and False for Public Domain, and is left
out for Unknown. The notice and URL are escaped for XML, and characters
XML 1.0 does not allow are left out. A document without a copyright
gets none of the properties.

Docs: docs/mcp.md (File Info), docs/target-app-parity.md.
2026-10-21 23:32:26 +07:00
Midasdf 32d012f6cc Print: Japanese paper sizes (JIS B4/B5/B6, はがき・封筒・四六判)
The Print dialog's Media list only had Letter/Legal/Tabloid and the ISO A
and B papers, so Japanese papers had to be entered as a custom size, and
"B4"/"B5" were ISO 216 (250 × 353 and 176 × 250 mm) although Japanese
printing uses the JIS sizes (257 × 364 and 182 × 257 mm, JIS X 6310).

Media gains JIS B4, B5 and B6 next to the ISO ones, and はがき
(japanese postcard, 100 × 148 mm), 往復はがき (japanese double postcard,
148 × 200 mm), 封筒 長形3号/長形4号 (japanese envelope #3/#4, 120 × 235
and 90 × 225 mm), 角形2号/角形3号 (kaku #2/#3, 240 × 332 and 216 × 277 mm),
四六判 (yonroku-ban, 127 × 188 mm) and A6, all in millimetres at 72/25.4.
b4 and b5 keep their ISO 216 dimensions so print settings and presets
saved before the Japanese papers existed keep their paper; the dialog
labels say "B4 (ISO)"/"B5 (ISO)" beside "B4 (JIS)"/"B5 (JIS)".

A paper is named by its JSON id or by its dialog label, any case and
spacing ("b5 (jis)", "B5 (JIS)" and "b5jis" all mean the JIS B5), so
agents and the control channel need not know the id spelling; an unknown
name is still refused. Media is written out instead of through the
choice! macro to carry that lookup.

The print.setup/file.print parameter docs and docs/mcp.md list the new
papers; the Print dialog picks them up from Choice::ALL with no UI code
change, and the Japanese labels are translated in ja.tsv (はがき,
往復はがき, 長形3号, 長形4号, 角形2号, 角形3号, 四六判).

Tests: japanese_paper_sizes checks every added size in points, that the
ISO B4/B5 values did not change, name resolution and id/label round
trips, and that each paper prints a PDF page with its own MediaBox size.

Validation: cargo fmt --all -- --check clean; cargo clippy
-p vectorcraft-pdf --all-targets -- -D warnings clean; cargo test
-p vectorcraft-pdf -> 330 passed, 0 failed (328 lib + 2 integration).
2026-10-21 23:32:26 +07:00
dotCooCoo 7d58ce4f9d Place: Word's low, medium and high kashida justification sets Insert Kashidas
A Word paragraph justified with w:jc lowKashida, mediumKashida or
highKashida placed as plain justified type, and its kashida length was
dropped. ECMA-376 Part 1, 17.18.44 (ST_Jc) gives these values a
slightly longer, a medium and the widest kashida length.

Such a paragraph now places justified with Insert Kashidas set to
Short, Medium or Long. A paragraph justified with both, and every other
alignment, still places with Insert Kashidas set to None. VectorCraft
writes no Word or RTF files, so this change does not affect export.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 23:32:26 +07:00
Drew DibbleandClaude Opus 5.5 e321c7179b Text: an embedder of the egui panels may leave the bundled fonts out
#2027 gave vectorcraft-text and vectorcraft-engine a `bundled-fonts`
feature for an embedder that supplies every document font at run
time, but vectorcraft-ui-egui (and vectorcraft-mcp) named the engine
with its default features, so an embedder that hosts the panels got
the fonts whatever it asked for. Each now has a `bundled-fonts`
feature of its own, on by default, that turns the engine's on, so a
downstream user keeps the fonts unless it asks otherwise. For a
member to leave the engine's default features out, the workspace has
to declare the engine without them, as it already declares
vectorcraft-text; the crates that used the engine's default now ask
for the fonts: the three apps and vectorcraft-testkit. Off,
vectorcraft-text's copy of the fonts (3.4 MB) is left out; theme.rs's
own copies of five of them, the panels' interface faces, stay either
way.

Nothing the workspace builds changes: for every workspace member and
for the whole workspace, with and without dev-dependencies, for all
targets, wasm32-unknown-unknown, aarch64-apple-darwin and
x86_64-pc-windows-msvc, `cargo tree -e normal[,dev] -f '{p} [{f}]'`
resolves the same features for every package as on main, apart from
the new feature names (the engine's `default` no longer listed;
ui-egui's and mcp's `bundled-fonts` and `default`).

A test of the panels can't see their configuration without the fonts
(their dev-dependencies turn them on), so `cargo xtask ci`'s
`no-bundled-fonts` step now also reads that tree for
vectorcraft-ui-egui and vectorcraft-mcp: with their default features
it must show the fonts, and without them, for any target (a wasm-only
dependency too), it must not. Documented in docs/development.md
(Fonts › Leaving out the bundled fonts).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 23:32:26 +07:00
Grant Colloty 84d5d8797f EPS/.ai editing data: type keeps stacked art styles as live appearance
**What:** Illustrator type styled through the Appearance panel came in as its drawn look (outlined compound paths). Comic and "text effect" templates are the common case, and the type couldn't be retyped. This keeps it as editable type with the style as a live VectorCraft appearance.

**Changes**
- **Art styles parsed as a tree.** Compound filters close at their `;`, basic ones at `/Part`. Each fill and stroke in a stack keeps its own effects, opacity and blend mode, and object-level effects stay on the object.
- **Type keeps any plain stack.** That covers several fills and strokes, pattern and gradient fills, Warp, Offset Path, Transform with copies, Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief. Bas Relief settings are left at their defaults, because Illustrator stores them in a Photoshop plug-in's binary block. Gradients and patterns are placed as the drawn look places them. Other objects keep the existing plain-style rule, since their drawn look is the more faithful option.
- **Point type without a frame matrix.** Illustrator 24 omits the matrix when point type is neither rotated nor scaled. Such stories were left out entirely; they're now read with an identity matrix.
- **Page comparison.** The page's rendering of a style isn't held to match VectorCraft's live rendering where the replaced drawn look lay, and styled made type isn't pruned as unseen.
- **EPS override.** `editingData: "always"` / `convert --editing-data` now applies to Illustrator EPS as well as `.ai`.

**Tests:** 6 new tests, written from scratch; `vectorcraft-eps` passes all 298 on current main. clippy `-D warnings` and rustfmt are clean for eps, engine and cli. `docs/file-format-parity.md` has been updated, including its revision history.

**Clean-room note:** I found the bugs with a user's licensed stock EPS, used locally only; nothing from it is committed. Filter and key meanings come from the public Illustrator Effect docs and public LiveEffect scripting examples (`jntp`, the Warp style order).

**Not included:** VectorCraft's pixel effects treat transparent content as empty instead of using Document Raster Effects Settings › Background (white by default). Because of that, Bas Relief on a transparent pattern gives flat grey. That needs a separate renderer change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-21 23:16:30 +07:00
KnottyDyesandClaude Opus 5.5 52b63e2198 PDF import: letter-spaced text keeps its word spaces
PDF import read every gap wider than a fifth of an em between two
glyphs as a space. Letter-spaced type (Tc, or glyphs placed one by one
with tracking) has such a gap between every pair of letters, so
"OPEN DAILY" came in as "O P E N D A I L Y".

A line now records every gap between its glyphs and decides which are
spaces when it finishes: the spacing its letters share (the gap a third
of the way up from the narrowest, when the line has three or more) is
taken off each gap first. That shared spacing is at most half an em, so
glyphs set wider apart, such as figures in columns, keep their spaces.
Right-to-left lines and glyphs along a curve read their gaps as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 23:16:29 +07:00
bflatastic 398c6316d9 G4: Fractional Widths rounds a type object's advances to whole points
Squashed from #3411 onto main.
2026-10-21 23:16:04 +07:00
Jürg Lehni 23dbf5d214 Threaded text: releasing a frame from a longer thread no longer duplicates its text
When two or more frames stayed in the thread, the first got the whole story while the others kept their shares, so the re-flow joined those twice. The frames after the first now start empty, and the story flows through them once.
2026-10-21 23:15:46 +07:00
Jürg Lehni af2618c9e4 Threaded text: type on a path threads, mixed with area type
Illustrator links type in an area or along a path (https://helpx.adobe.com/illustrator/using/manage-text-area.html); until now only area type threaded here. Type on a path that overflows ends its line where its text stops fitting, so a story flows on from it, and its red out port, just past its end bracket, loads the Selection tool with the text as area type's does. A new frame threaded from it at a point is type on a path of its shape.
2026-10-21 23:15:45 +07:00
dotCooCoo 5ac70a9b07 Brush Options: Tips shows each colorization method on a sample
Scatter, Art and Pattern Brush Options had no Tips button. The
reference app's Brushes page has one for Colorization, with information
and samples about each method.

A Tips button beside Method now opens Colorization Tips. For each
method it shows the name, what the method does, and a sample of the
brush as OK would make it, painted with that method on a blue stroke.
Hue Shift's sample uses the dialog's Key Color. A second click on Tips,
Escape or a click outside the popover closes it, and the dialog field
tips opens it from a command. Screen readers name the button, the
popover and each sample.

In every dialog, Escape with a dropdown list open now closes the list
and leaves the dialog open. Before, it canceled the dialog and
discarded its edits.

The stroke preview and the samples no longer panic when ui.dialog.set
writes a brush definition (__def) that is not a JSON object.

Docs: docs/target-app-parity.md.
2026-10-21 23:15:43 +07:00
bflatastic bbd9f51096 G4: No Break keeps marked characters on one line
Squashed from #3398 onto main.
2026-10-21 23:15:27 +07:00
dotCooCoo 7d6eb3246e Type: Optical Margin Alignment hangs punctuation in right-to-left paragraphs
Optical Margin Alignment and Roman Hanging Punctuation left every
right-to-left paragraph of point and area type, and type on a path
whose first paragraph runs right to left, as it was: the quotation
marks, full stops and commas at the ends of its lines stayed inside
the margins.

A right-to-left line now hangs at the margins its alignment places it
against, as a left-to-right line does. Auto aligns a right-to-left
line at its right, so it hangs there. The line is read from right to
left: where it hangs at its left margin, a full stop and the quotation
mark after it that end the line hang together. In vertical type,
where the characters of a right-to-left paragraph run from the top
down, the pair is read from the top.

Type on a path whose first paragraph runs right to left hangs at its
brackets by the same rule. In horizontal type, Auto places it at the
end bracket, so it hangs there. On a closed path, its hang at the end
bracket gets the space between the end and start brackets before its
hang at the start bracket does, as in right-aligned type.

A justified right-to-left line of one word is spread between its
letters as they are drawn. The gap after each letter went to the
letter that follows it in the text, so the two leftmost letters
touched and the word ended one gap short of the right margin.

Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
2026-10-21 23:15:07 +07:00
dotCooCoo 963922beaf Symbols: F8 opens Symbol Options for a new symbol from the selection
The reference app creates a new symbol with F8 on Windows and macOS.
VectorCraft opened Symbol Options for a new symbol from the Symbols
panel's New Symbol button, from the panel menu's New Symbol… item and
from Help › Search Commands…, but no key ran it, and F8 did nothing.

ui.symbolOptions (Symbol Options…) now has the default F8 on every
platform. With a selection the key opens Symbol Options for a new
symbol, as the New Symbol button does: OK makes the symbol and puts an
instance of it in place of the art, and Cancel makes none. With nothing
selected the key opens no dialog, and the status bar says that nothing
is selected. Shift+F8, Cmd+F8 and Cmd+Shift+F8 keep their commands.
Keyboard Shortcuts now lists the command with its key.

Docs: docs/ui-parity.md, docs/target-app-parity.md,
docs/control-protocol.md.
2026-10-21 23:15:07 +07:00
bflatastic b008e83658 Brushes: New Brush asks for the type and opens its Brush Options
Squashed from #3390 onto main.
2026-10-21 23:15:05 +07:00
dotCooCoo 79c4647665 Dialogs: sliders step their field's number with the arrow keys
A dialog's slider could take the keyboard focus from Tab, but it
responded only to the pointer, so an arrow key moved the focus to the
nearest widget in the arrow's direction and a keyboard user could not
move the slider. Screen readers could not step it either.

These dialog sliders, each beside a number field, now respond to the
keyboard. A click or Tab gives a slider the focus, which an outline
shows. Then the Right and Up arrows step the field's number up to the
next whole number and the Left and Down arrows step it down to the
next whole number. Shift with an arrow, Page Up and Page Down step it
by ten, and Home and End set the slider's first and last value. No
step goes past either end, and a number past the slider's range steps
from its nearer end. Screen readers' increment and decrement actions
step the slider as the arrows do. A step sets the field's number in
the field's own unit, so a Move slider in a millimeter document steps
one millimeter. When the field holds a number past the slider's range,
Home, End or a drag to that end of a form slider now sets the end's
number; before, such a drag left the field's number as it was.

The form sliders get this through the new color_slider_stepped, next
to color_slider_valued, and so do the Pencil, Paintbrush and Smooth
Fidelity, the Perspective Grid's Opacity, the 3D dialogs' sliders and
the flattener's balance, which the Flattener Preview panel also shows.
The Color panel, Swatch Options, the Color Picker's channel slider,
the Navigator and the Character panel keep their sliders as they were.

Docs: docs/target-app-parity.md.
2026-10-21 23:14:36 +07:00
dotCooCoo 4b56ce44a1 Brushes: renaming or deleting a brush updates every stroke that uses it
Delete Brush and a rename in Brush Options changed only the strokes in
the layers. Strokes in symbol definitions, graph designs, pattern
tiles, opacity masks, graphic styles and the art of other brushes kept
the old brush name. After a rename they drew as plain strokes, and
after a delete a brush made later with the same name painted them.

brush.delete and brush.options with newName now rename the brush, or
clear it, in every stroke in those places, and in the tile that
pattern editing's Cancel puts back. Only the objects that change, and
the groups that hold them, are copied. It is still one undo step.

Docs: docs/target-app-parity.md.
2026-10-21 23:14:35 +07:00
dotCooCoo b7ea047d81 Effects: Expand Appearance names each expanded copy by its own id
Art evaluated from an object can hold one id more than once until the
pieces get ids of their own. The reply then named a repeated id by the
new id of its first node: for a square with a random Transform on its
stroke, in a group whose Transform effect copies it, the reply listed
the copies' group instead of the two copies. An object with a color
adjustment and crop marks was listed twice.

The expansion now records each object it lists with its place in the
art, and the new ids are taken from those places. An object is listed
once.
2026-10-21 23:14:34 +07:00
dotCooCoo 8b67c4a943 Effects: expanded, flattened and exported art keeps the random Transform the canvas drew
Random in a Transform effect takes its seed from the object's id. When
art evaluated from an object repeats ids (the copies a Transform effect
makes, a copied group's members included, and the object under Crop
Marks), Expand Appearance and the baking in bake_document gave the
pieces new ids before expanding or baking their effects. The result
then drew with other random values than the canvas did: on main, 2,382
to 5,205 pixels of a 200 x 200 render changed after Expand Appearance,
and 7% to 14% after Flatten Transparency, for 50 pt squares with a
random Transform on their stroke, on their group or under Crop Marks.

bake_pieces now bakes each piece with the ids it has and gives new ids
after. expand_pieces expands the object's own pieces, and the other
members that repeat an id and hold a random Transform, before their
ids change. Expand Appearance now expands a node value
(expanded_node). The Crop Marks branch expands the object before the
object and the marks get new ids. The reply still lists the expanded
object first, then what was expanded inside it.
2026-10-21 23:14:34 +07:00
dotCooCoo 7aa25698d2 Brushes: dragging a brush reorders it within its type
The reference app reorders a brush that is dragged to a new place in
its Brushes panel, among the brushes of its type only. VectorCraft's
Brushes panel could not reorder brushes, and no command moved one.

brush.move {name | names, to?, type?} moves brushes to index to among
the brushes of their type, in brush.list order, as one undo step. A
move that keeps the order records no step. Brushes of different types,
or a type other than theirs, are refused with "brushes move only within
their type". In the Brushes panel, a brush dragged onto another brush
of its type goes before or after it by the half of the tile or row
under the pointer, and a bar marks the slot while it is held there. A
brush dropped on a brush of another type stays where it is, and the
status bar shows the refusal.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 23:14:33 +07:00
dotCooCoo b4fef5dc98 Library panels: a persistent panel whose file is gone stays listed as missing
At startup, a persistent swatch or graphic style library panel whose
file could not be read was closed and left out of the preferences,
with only a status-bar note. The next preferences save dropped it, so
moving or renaming the file once lost the panel and its settings.

Such a panel now reopens where the saved layout had it and stays in
the preferences. Its tab shows the file's name without the extension,
and its body shows "Missing:" with the file's path and a Locate
Library… button. The new command ui.libraryPanel.locate {panel,
path?} loads the library from the file given, or from one picked in
an open dialog, and shows it in the same panel, which keeps its view,
Show Find Field and Persistent. When the file's library is open in
another panel, the missing panel closes and that panel is brought
forward and made persistent.

A persistent panel whose User Defined library file is deleted while
the app runs shows the library as missing the same way; other panels
still close. window.swatchLibrary and window.graphicStyleLibrary take
a missing library's id, so a closed missing panel opens again.
Previous Library and Next Library are dimmed in a missing panel's
menu.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-21 23:14:32 +07:00
dotCooCoo 7d4ae444b6 Docs: localization parity lists the Dutch, Danish and Indonesian catalogs
main registers nl, da and id catalogs with complete_menus, but
docs/localization-parity.md gave Indonesian 0 rows and the status
"none", had no Dutch or Danish row, and said "Nineteen non-English
catalogs".

The Indonesian row and new Dutch and Danish rows give the counts on
main at cb0a7d9: 5,279 rows, 750 of them @msg, for id and da, and 5,317
and 777 for nl. None of the three is in COMPLETE_MESSAGES, so the rows
say that the tests hold their menus and tl! literals complete but not
their messages. The page now counts 23 non-English catalogs. By the
page's rule (rows over 4,020, capped at 100% per part), Indonesian's
term in the 12-language score goes from 0 to 100, and the score goes
from ~74% to ~82%.

Docs: docs/localization-parity.md.
2026-10-21 23:14:31 +07:00
KnottyDyesandClaude Opus 5.5 bc3351eacc ICC: all built-in profiles are checked for aligned sizes and tags
The #1584 regression covered the four built-in RGB spaces. It now also
checks gray and both CMYK profiles: header size equal to the length, a
length that is a multiple of 4, tag offsets on 4-byte boundaries, tag
data within the profile, and that the profile still parses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 23:14:28 +07:00
dotCooCoo bc6ffd9c1e Docs: localization parity names the counting commit and what each i18n check covers
The rows with a count date were counted on main at cb0a7d9; the method
sentence now names that commit. The sentence on the tests now says that
complete_menus covers menu strings and tl! literals, COMPLETE_MESSAGES
covers status and error messages, and Bulgarian is in the first only.
2026-10-21 23:14:27 +07:00
oracola91 d8e876b721 Data Recovery: the recovery folders are owner-only on Unix
Recovery copies are unsaved documents. FolderStore made its folders with
create_dir_all, so on Unix they got the default mode and other accounts on
the machine could read the copies. Folders the store creates are now 0o700.
A store folder that already exists (it can be one picked in Preferences)
keeps its mode; the area made inside it is owner-only.

The same change as storytold/pdfcraft#3320.
2026-10-21 23:14:27 +07:00
dotCooCoo 80088bbf25 Docs: localization parity row counts match the catalogs on main
docs/localization-parity.md gave the zh-hant catalog 4,741 rows, 742
of them @msg, while the catalog in #2784 had 4,784 and 756. On main it
has 4,892 and 790. Other rows that give a row count were also behind
main. For example, the Korean row gave 4,151 rows (4,870 on main), and
the Swedish row gave 668 @msg rows (795 on main).

Thirteen rows now give the counts on main, with "(counted 2026-10-21)".
A row is a non-blank line of the catalog that does not start with "#",
the rule that gives the page's French reference of 4,020 rows at
01e165af. The method sentence now says that a row with a count date was
counted on main on that date. The "N / 4,020" rows and every percentage
stay as they were.

The Bulgarian row said the tests hold its messages complete, but bg is
not in COMPLETE_MESSAGES. It now says the tests hold its menus and tl!
literals complete. The Portuguese row's "one|other plurals" gave the
row nine cells in an eight-column table. It now reads "one/other
plurals".

Docs: docs/localization-parity.md.
2026-10-21 23:14:27 +07:00
dotCooCoo 04bf2dcab9 Tests: the data set export test checks the whole file in a folder emptied first (#2969)
variables_panel_data_sets_ask_off_the_ui_thread checked only that the
exported file started with the header, so an export of the header
alone passed. Its folder was named after the process ID and was used
as it was found, so an out.csv left by an earlier run with the same ID
passed the check even when the export wrote nothing.

The test now empties its folder from vectorcraft_testkit::temp_dir
before use, and checks that the file holds the header and the row for
data set B and nothing else.
2026-10-21 23:14:26 +07:00
dotCooCoo 76058fb1b5 Type: the AltGr tests expect Ctrl+Alt+L to open Layer Options
Since #3355, Ctrl+Alt+L is New Layer Options… and opens Layer Options
for a new layer instead of adding a layer. On main merged with this
branch, ctrl_alt_chords_without_typed_text_still_run failed: its
Ctrl+Alt+L steps expected a new layer. They now expect Layer Options
in mode new with no layer added, while the Type tool edits and with
the Selection tool, and the test cancels the dialog between the two.

The doc comment of altgr_characters_typed_in_text_run_no_command names
New Layer Options… (Cmd+Alt+L) instead of New Layer, and the
docs/target-app-parity.md line says AltGr+L does not also open Layer
Options for a new layer.
2026-10-21 23:14:25 +07:00
dotCooCoo acb017e1f1 Type: a character typed with AltGr while editing text runs no command
Firefox on Windows reports AltGr as Ctrl and Alt held. In the web
build, while the Type tool edited text, a character typed with AltGr
was typed and also ran the command chord of its key: AltGr+L (ł on a
Polish layout) added a layer through Cmd+L, AltGr+Q (@ on a German
layout) ran Reset Tracking and Kerning, AltGr+E (€) opened Export for
Screens, AltGr+A (ą) ran Select All on Active Artboard, and AltGr+Z
(ż) undid the typing through Cmd+Z.

While the Type tool edits text, a key press with Ctrl and Alt held
that is followed by the text it typed is now dropped before the
command chords are matched. A Ctrl or Ctrl+Alt chord that types no
character runs its command as before, and key presses outside text
editing are handled as before. The desktop app receives AltGr
without Ctrl and Alt, and a press with Ctrl held types no text
there, so it behaves as before.

Docs: docs/target-app-parity.md.
2026-10-21 23:14:25 +07:00
dotCooCoo da197ba75c Tests: the half-form test uses only a fallback face that forms Ka's half form (#2670)
a_zero_width_joiner_in_devanagari_asks_for_a_half_form took any
fallback face whose GSUB script list holds dev2 or deva, and then
asserted that Ka takes its half form. A face with the script tag and
no half form for Ka failed the test, although the text was shaped in
the fallback face as the test checks.

The test now takes a fallback face only when the face lists dev2 or
deva and Ka + halant + ZWJ + Ssa set in that face alone starts with
Ka's half form and ends with Ssa. It prints SKIPPED when no installed
face does. A new test runs the filter on two faces built in code that
both list dev2. It takes the face with a half feature and passes
over the face without one.
2026-10-21 23:14:24 +07:00
dotCooCoo bac132d8d4 Docs: the document.open pages sentence names the file saved without PDF compatibility
docs/mcp.md said that `pages` that name every page "of such a file"
open it from its editing data. The sentence before it is about a file
without editing data, so "such a file" could be read as that file.

The sentence now says "a file saved without PDF compatibility".

Docs: docs/mcp.md.
2026-10-21 23:14:23 +07:00
bflatastic 1f6a76afa8 Save As: a typed extension of a format the save doesn't write is replaced
Squashed from #3372 onto main.
2026-10-21 23:14:20 +07:00
Nikolai Nyegaard 48263b339d Preferences: list the languages alphabetically by their own name
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
2026-10-21 23:14:17 +07:00
Dylan M. Taylor cb0a7d9b31 feat(window): adopt shared craft-window for Linux decorations (depends on craft-libs#217) (#3366)
* feat(window): adopt shared craft-window for Linux decorations

Decide the window frame on Linux/BSD via the shared craft-window crate
(storytold/craft-libs) instead of VectorCraft's unconditional
`CUSTOM_TITLEBAR = !macos`, so the app matches the rest of the Craft
suite per desktop.

- Probe `craft_window::Choice::probe(Prefix::new("VECTORCRAFT"))` once at
  startup; custom titlebar is on when the frame is `Frame::App(_)` and the
  user has not opted into the system title bar.
- Preserve the `systemTitleBar` preference: opting in forces the native
  (desktop) frame back on, as before.
- Keep macOS and Windows behavior exactly as today (decided by
  `CUSTOM_TITLEBAR`, not the probe): macOS integrated title strip,
  Windows app-drawn caption. craft-window returns `Frame::Desktop` for
  both; VectorCraft's overdraw is its own choice.
- The only behavioral change: KDE/Wayland now keeps KWin's native
  decorations (Frame::Desktop) instead of always drawing a custom
  titlebar; GNOME still draws its own (Frame::App).
- `VECTORCRAFT_TITLEBAR` override and the `systemTitleBar` pref both still
  work (the env var is craft-window's override for the `VECTORCRAFT` prefix).

NOT MERGEABLE YET: craft-window is open PR storytold/craft-libs#217; the
dep points at the PR branch and must be repointed to a merged `rev`
before this can merge.

* fix(window): native frame when X11 is forced; pin craft-window to merged rev

- When Preferences > Linux Display Server > X11 forces the window onto
  XWayland, treat the frame as the desktop's (native X11 decorations)
  rather than letting a GNOME/Wayland probe pick Frame::App and draw our
  own title bar on what is really an X11 window. An explicit
  VECTORCRAFT_TITLEBAR override still wins (checked via Choice::overridden).
- Repoint craft-window from the (now-merged) feature branch to the squash
  commit on storytold/craft-libs (craft-libs#217); regenerate Cargo.lock.
- The WAYLAND_SOCKET concern raised in review is handled in the merged
  craft-window crate itself (the probe skips when WAYLAND_SOCKET is set).

Verified: cargo build + test -p vectorcraft (131 tests) pass against the
merged dep; windows7 (--no-default-features) still compiles; rustfmt clean.

* fix(window): only force native frame when X11 libs are actually present

Follow-up to review: the XWayland title-bar override now also checks
xwayland::x11_libraries_found(), matching the launch path's own condition.
If X11 is requested but the libraries are missing, startup stays on
Wayland, so the frame decision must keep the probe's app-drawn result
rather than flipping to native. Verified: tests pass, rustfmt clean.
2026-10-21 07:51:10 -04:00
BFlat 0b94055cfe Merge pull request #2182 from dotCooCoo/fix/paste-ai-editing-data
PDF/.ai import: a .ai saved without PDF compatibility is placed and pasted from its editing data
2026-10-21 17:35:32 +07:00
BFlat fe8c0f16f0 Merge pull request #2595 from dotCooCoo/feat/outline-object-preview-bounds
Use Preview Bounds: objects are measured with their geometry effects
2026-10-21 17:35:17 +07:00
BFlat bdcaf0da46 Merge pull request #2403 from dotCooCoo/feat/artboard-background-color
Artboards: each artboard can have a background color on the canvas
2026-10-21 17:35:01 +07:00
BFlat 18594e8228 Merge pull request #3329 from dotCooCoo/feat/web-reprojects-while-the-view-changes
Web: a pan or zoom of a heavy document moves the last frame and renders once the view is still
2026-10-21 17:34:45 +07:00
BFlat ec3d0076d1 Merge pull request #2688 from dotCooCoo/fix/pdf-import-drawn-space-gaps
Part of #2384: imported PDF text keeps its place when the file draws the space beside a wide gap
2026-10-21 17:34:30 +07:00
BFlat 60ed162fe8 Merge pull request #2557 from dotCooCoo/feat/shape-builder-options
Shape Builder: double-clicking the tool opens Shape Builder Tool Options
2026-10-21 17:34:15 +07:00
BFlat 06be77c967 Merge pull request #2497 from dotCooCoo/feat/artboard-tool-shift
Artboard tool: Shift-drag draws an artboard inside another or marquee-selects artboards
2026-10-21 17:34:00 +07:00
BFlat 44370f9e62 Merge pull request #3012 from 00xJS/fix/2950-press-quality-cmyk
PDF presets: Press Quality converts to Document CMYK, keeping its numbers (#2950, #1242)
2026-10-21 17:33:45 +07:00
BFlat 7c72fa5fba Merge pull request #1772 from AyberkA0/fix/1771-ai-process-swatch
AI import: a gradient stop naming a process colour leaves its swatch process (#1771)
2026-10-21 17:33:28 +07:00
BFlat a85d167b03 Merge pull request #3364 from dotCooCoo/fix/wheel-hover-follows-the-moved-view
Canvas: the pointer's document point follows a wheel scroll or view drag in the same frame
2026-10-21 17:33:01 +07:00
BFlat 50336e8021 Merge pull request #3352 from dotCooCoo/feat/paintbrush-close-cursor
Pencil and Paintbrush: holding Alt shows the closing loop cursor
2026-10-21 17:32:46 +07:00
BFlat 1362b40588 Merge pull request #3350 from dotCooCoo/feat/artboards-rename-several-numbered
Artboards: Rename gives several selected artboards one name, numbered
2026-10-21 17:32:32 +07:00
BFlat 3d890d93fd Merge pull request #3345 from dotCooCoo/fix/brush-colorize-type
Brushes: colorization recolors type in scatter brush art
2026-10-21 17:32:16 +07:00
BFlat 441c543a66 Merge pull request #3336 from dotCooCoo/fix/issue-1816
Part of #1816: Snap to Grid lands a moved object by its nearest edge and no longer snaps its center
2026-10-21 17:32:01 +07:00
BFlat de6f153032 Merge pull request #3306 from HEOJUNFO/fix/path-only-images
Pick placed images on their frame with Selection by Path Only
2026-10-21 17:31:47 +07:00
BFlat 84c21707b9 Merge pull request #2682 from dotCooCoo/fix/engine-language-test-runs-japanese
Tests: the engine-language test runs a Japanese frame again
2026-10-21 17:31:30 +07:00
BFlat b94311c97b Merge pull request #2672 from dotCooCoo/fix/svg-live-text-shrink-to-fit
Export: area type that Shrink Text to Fit scales down is written at the size it draws at
2026-10-21 17:31:14 +07:00
BFlat 3854155f18 Merge pull request #2671 from dotCooCoo/fix/isolate-refuses-locked-art
Isolation mode: object.isolate refuses hidden or locked objects and locked guide paths
2026-10-21 17:30:56 +07:00
BFlat 27a6993a8d Merge pull request #2596 from dotCooCoo/fix/bg-uk-blend-spine-term
i18n: each catalog names the blend spine with one term
2026-10-21 17:30:40 +07:00
BFlat 747d7096fb Merge pull request #3207 from Taha-Mahmoodi/chore/wasm-warnings-dup-check
Build: the wasm check is warning-free; one file-dialog check instead of two
2026-10-21 17:30:24 +07:00
BFlat 36338753af Merge pull request #3355 from dotCooCoo/feat/new-layer-options-key
Layers: Cmd+Alt+L opens Layer Options for a new layer
2026-10-21 17:30:05 +07:00
BFlat c299686326 Merge pull request #3351 from dotCooCoo/feat/blob-brush-shift
Blob Brush: Shift keeps the stroke horizontal or vertical
2026-10-21 17:29:51 +07:00
BFlat 36d7f3b3b6 Merge pull request #3348 from MarekSpodBiedry/issue-3309
Draw from centre (Alt) now applies to the typed size of a rectangle or ellipse (#3309)
2026-10-21 17:29:33 +07:00
BFlat b597b5bbe0 Merge pull request #3310 from dotCooCoo/feat/optical-margin-type-on-a-path
Type: Optical Margin Alignment hangs punctuation at the ends of type on a path
2026-10-21 17:29:16 +07:00
BFlat 5973245909 Merge pull request #3288 from dotCooCoo/feat/kashida
Paragraph: Insert Kashidas justifies Arabic lines with kashidas
2026-10-21 17:29:01 +07:00
BFlat 294ba818a7 Merge pull request #3295 from dotCooCoo/feat/every-line-mixed-sizes
Type: the Every-line Composer composes paragraphs that mix type sizes in rectangular area type
2026-10-21 17:28:46 +07:00
BFlat 254fb1605b Merge pull request #3287 from dotCooCoo/fix/undo-groups-close-only-their-document
Undo groups: library styles and field scrubs close only the group they open
2026-10-21 17:28:31 +07:00
BFlat e013061c9a Merge pull request #3294 from dotCooCoo/fix/undo-group-cancel-cuts-the-earliest-journal-entry
Undo groups: a cancel drops only the canceled groups' entries from the journal
2026-10-21 17:28:16 +07:00
BFlat 1f73cdf7bc Merge pull request #3293 from dotCooCoo/fix/eps-errors-explain-postscript-error-names
EPS import: a PostScript error shows what its name means in the interface language
2026-10-21 17:28:00 +07:00
BFlat 06cf9d5dea Merge pull request #3311 from dotCooCoo/feat/graph-column-design-totals
Graphs: column designs show each column's value where their type has % and two digits
2026-10-21 17:27:45 +07:00
BFlat dff3a18584 Merge pull request #3318 from dotCooCoo/feat/tabs-panel-vertical-type-labels
Tabs: in vertical type the alignments read Top- and Bottom-Justified Tab and the position Y
2026-10-21 17:27:30 +07:00
BFlat 77c58db7b6 Merge pull request #3322 from dotCooCoo/fix/objects-on-path-refuses-live-containers
Objects on Path: clipping paths and parts of blends, envelopes and repeats stay in place
2026-10-21 17:27:14 +07:00
BFlat e84073e535 Merge pull request #3320 from dotCooCoo/fix/color-panel-new-swatch-keeps-the-color-model
Swatches: a swatch made from a color the app shows keeps that color's model in a CMYK document
2026-10-21 17:26:58 +07:00
BFlat 10eba8be69 Merge pull request #3327 from dotCooCoo/fix/live-paint-merge-keeps-the-group
Live Paint: Merge keeps the group's lock, visibility, mask and graphic style link
2026-10-21 17:26:42 +07:00
BFlat e407202107 Merge pull request #3305 from dotCooCoo/fix/live-paint-click-selects-hovered-edge
Live Paint: a Live Paint Selection click outside the faces selects the edge it highlights
2026-10-21 17:26:25 +07:00
BFlat 04ca2f066d Merge pull request #3177 from lehni/refactor/file-pick-filters
UI: FilePick filters own their text, so a dialog's file types can be made at run time
2026-10-21 17:26:09 +07:00
bflatastic 30203e60a1 PDF/.ai import: a .ai saved without PDF compatibility is placed and pasted from its editing data
Squashed from #2182 onto main.
2026-10-21 17:11:08 +07:00
dotCooCoo a5ea3a8002 Use Preview Bounds: a handle drag keeps the opposite visible edge in place
With Use Preview Bounds on, a bounding-box handle drag scaled the art by
the ratio of the visual box, so margins that stay (a stroke with Scale
Strokes & Effects off, an Offset Path reach) or scale by another factor
moved the opposite visible edge: dragging the right edge of a square with
Offset Path 10 from 210 to 310 left its left edge at 98.33 instead of 90.

The Selection tool now previews object.setBounds with the dragged visual
size and the opposite handle (with Alt, the center) as the reference
point, so the scale is solved from the geometric box and the margins the
visual box adds, for strokes with Scale Strokes & Effects on or off and
for geometry effects. Shift and Alt drags follow the same rule; a drag
that flips the box, and one with anchors selected, still scale by the
matrix. A previewed object.setBounds runs as object.transform with the
transform it finds, so Transform Again repeats the drag and the journal
holds that transform. The drag transforms a path of a compound path
selected on its own, as object.transform does, and leaves the art at
least 1e-4 of its width and height.
2026-10-21 17:11:08 +07:00
bflatastic dda52d0b20 Artboards: each artboard can have a background color on the canvas
Squashed from #2403 onto main.
2026-10-21 17:11:08 +07:00
dotCooCoo 9acfd1ea8a Web: a pan or zoom of a heavy document moves the last frame and renders once the view is still
The web build has no render worker, so every frame of a pan, zoom or
rotation of the view rendered the whole canvas on the browser's main
thread before it was shown. In Chrome, each of the 20 steps of a
Space-drag over a document of 50,000 paths took a frame of 533 to
900 ms.

In the web build the canvas now waits while only the view changes on
a document whose last render took more than 8 ms. It draws the last
frame moved, scaled or turned with the view, as the desktop app does
while its worker renders, and it renders once the view has been still
for 0.2 s. The same Space-drag now keeps every frame within 17 ms and
renders once, in 667 to 767 ms, after the view stops. After an edit, a
preview or an option change the canvas renders at once. The desktop
app renders as before, with or without its worker.

Docs: docs/target-app-parity.md.
2026-10-21 17:11:08 +07:00
dotCooCoo f009f4f3e5 Part of #2384: imported PDF text keeps its place when the file draws the space beside a wide gap
PDF import gives a space read from a gap between two glyphs tracking of
its own when the line's tracking cannot place the glyphs after it. A
space glyph that the file draws was not measured this way: the gap
before or after it was spread over the whole line as one tracking
value, so the words after it moved. VectorCraft's own PDF export draws
a tracked space and leaves its tracking after it.

A space the file draws between two glyphs on a line is now measured
from the end of the glyph before it to the start of the glyph after it,
and takes tracking of its own on the same terms as a space read from a
gap. Two or more spaces in a row, and a space at the start or end of a
line, keep the line's tracking. The tracking calculation builds a map
of the line's laid-out advances by byte offset once.

A wrapped line whose spaces each have tracking of their own, within a
fiftieth of an em of the first space's tracking, can still come back as
part of justified area type. Wrapped lines whose spaces' trackings
differ by more than that stay point type with their words in place.
2026-10-21 17:11:08 +07:00
dotCooCoo 1adca4f484 i18n: Georgian rows for Shape Builder Tool Options
Georgian is a complete-menu catalog on main, so the dialog's seven new
labels need rows in ka.tsv.
2026-10-21 17:11:08 +07:00
dotCooCoo d532e6d341 Use Preview Bounds: a clip group is measured where its clipping path's stroke paints
The renderer clips a clip group by its clipping path's geometry before
the path's geometry effects, and paints the path's strokes over the
clipped art without clipping them, with those effects. With Offset Path
on a stroked clipping path, the stroke paints along the offset shape
while the group was measured by the path's own stroke.

With Use Preview Bounds on, a clip group or layer whose clipping path
has strokes with geometry effects is now measured as the clipping
path's geometry and those strokes as drawn, in the Transform panel, the
bounding box, Align and Distribute. This holds inside groups and for a
clip group with a fill or stroke of its own. A clipping path
without a stroke still measures as the region it clips to, and a hidden
one adds no stroke.
2026-10-21 17:11:08 +07:00
dotCooCoo 1d2f50ec71 i18n: Dutch, Danish and Indonesian rows for Shape Builder Tool Options
Dutch, Danish and Indonesian are complete-menu catalogs on main, so the
dialog's seven new labels need rows in nl.tsv, da.tsv and id.tsv.
2026-10-21 17:11:08 +07:00
dotCooCoo 9f31471042 Use Preview Bounds: the selection tools measure the selection once per revision
With Use Preview Bounds on, every hover frame's cursor and every
pointer move measured the selection again, and with Smart Guides on
each selected object too, evaluating their geometry effects each time.

The session now keeps a PreviewMemo, which ToolContext::preview_memo
passes to the tools. selection_bounds, selection_box and the object
bounds that Smart Guides find centers in while hovering read it. The
memo holds the measurements for one key: the document's uid and
revision, the selected objects and Use Preview Bounds. A new key
replaces them. VectorcraftApp::selection_box in the UI keys on the
uid, the revision and Use Preview Bounds. A ToolContext without a
memo measures on every call.
2026-10-21 17:11:08 +07:00
dotCooCoo 1267efbcf9 Use Preview Bounds: objects are measured with their geometry effects
With Use Preview Bounds on, the bounding box, the Transform panel,
Align, Distribute, Distribute Spacing and object.setBounds measured
each object without its geometry effects. A square with Offset Path
10 measured as the square, and type with Outline Object measured by
its layout box instead of its glyphs.

The new vectorcraft_effects::preview module measures objects as they
are drawn. A path is measured by its effected shape, with the
geometry effects of each fill and stroke that paints. Type, images,
live objects and symbol instances are measured by their reshaped art.
A group or layer with geometry or Pathfinder effects, or with a fill
or stroke of its own that has geometry effects or paints members with
geometry effects, is measured by its evaluated art. A blend, envelope
or repeat whose objects have geometry effects is measured by its
evaluated art, with those objects measured after their effects. Raster
effects are not measured. In one object, the art of at most 4,096 symbol
instances is measured with its geometry effects, and the other
instances are measured without them. With Use Preview Bounds off, the
measures are unchanged.

object.setBounds sizes objects with geometry effects by measuring
them after each scale it tries, so type with Outline Object set to
100 pt wide gets a glyph box 100 pt wide.

Selection::anchors_box is split out of Selection::anchor_bounds. The
doc comment of VectorcraftApp::selection_bounds said that it includes
effects; it now says that it leaves them out.
2026-10-21 17:11:08 +07:00
dotCooCoo e5b1f0c191 Shape Builder: double-clicking the tool opens Shape Builder Tool Options
Double-clicking the Shape Builder tool reported that the tool has no
options. The reference app opens Shape Builder Tool Options there.

The dialog sets Consider Open Filled Paths as Closed, Pick Color From
(Artwork or Color Swatches), Cursor Swatch Preview, Selection (Freeform
or Straight Line) and Highlight Fill. The defaults keep today's
behavior. With Consider Open Filled Paths as Closed off, a filled open
path cuts regions as an unfilled one does. With Color Swatches, a merge
fills the new shape with the current fill, and the Left and Right arrow
keys step the current fill through the document's color swatches.
Cursor Swatch Preview shows the current fill and the swatches before
and after it above the pointer. With Straight Line, a drag merges what
the line from its first point to its last crosses. With Highlight Fill
off, merge mode shades no regions.

The options persist like other tools' options. shapeBuilder.merge
takes openFilledAsClosed and pickColorFrom, and shapeBuilder.regions
takes openFilledAsClosed. Gap Detection, "In Merge Mode, Clicking
Stroke Splits the Path" and Highlight Stroke When Editable are not part
of this change.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 17:11:07 +07:00
dotCooCoo 6f95f1cf09 i18n: Dutch, Danish and Indonesian rows for the Artboard tool's Shift hint
main now lists nl, da and id as complete-menu catalogs, so the two hint
fragments this branch adds need rows there too. Each row replaces the
old " on the canvas to create one" row, as in the other catalogs.
2026-10-21 17:11:06 +07:00
00xJ0 096544db40 Test: Press Quality keeps a CMYK image's inks in an RGB document (#3012 review)
The colour space alone could pass a conversion that changed the inks: the exported image is
opened again and its inks compared with the ones placed.
2026-10-21 16:55:17 +07:00
Ayberk A. 0d4b597983 AI import: a gradient stop naming a process colour leaves its swatch process (#1771)
A colour named by a gradient stop (`Bs`) was collected with the colours the
colour operators define, in file order, and the first definition of a name
made its swatch. A stop can't say whether its colour is an ink, so it reads
as a spot colour; and since the file defines its gradients before the art,
a global process colour set with `Xk` that a gradient also names came in as
a spot swatch, which PDF export then writes as a Separation. The stops'
named colours are now kept apart and make swatches only after the colour
operators' colours, so `Xk`'s definition wins and a name only a stop uses
still gets its swatch. The stop's values were already read right since the
lexer reads a `%_BS` line's own operands.
2026-10-21 16:55:17 +07:00
dotCooCoo 37a8aa4946 Canvas: the pointer's document point follows a wheel scroll or view drag in the same frame
handle_input set hover_doc, the document point under the pointer,
from the view as handle_input found it. The wheel's zoom, scroll and
trackpad turn, a held pan, a Rotate View drag, the Zoom tool and a
Presentation Mode click moved the view later in the same call, and
show drew with the moved view. In a frame where a wheel scroll moved
the view, the rulers' pointer marks were drawn off the pointer by that
frame's scroll, the Info panel showed the earlier point, and the hover
highlight and the tool cursor were looked up there. A pan, a Rotate
View drag, a Zoom tool marquee and an Animated Zoom scrub left
hover_doc behind the view in the same way. A tool drag held still
while the wheel scrolled was sent the old view's point.

The wheel block now runs before handle_input takes its view, so the
tool's events in that frame use the view the wheel moved to. show sets
hover_doc right after handle_input returns, from the view it left.
2026-10-21 16:55:17 +07:00
dotCooCoo f761ae2585 Pencil and Paintbrush: holding Alt shows the closing loop cursor
Holding Alt while drawing with the Paintbrush, or with the Pencil while
Alt key toggles to Smooth Tool is off, closes the path at the release,
but the cursor did not change. The reference app's Paintbrush shows a
small loop while Alt is held, and its Pencil shows a small circle.

Cursor::PaintbrushClose and Cursor::PencilClose show while Alt is held
with the Paintbrush, and with the Pencil while that option is off,
before the press and while drawing. Both tools still borrow the
crosshair, so the closing cursors are drawn as that crosshair with the
PenClose ring. Use Precise Cursors turns them into the plain crosshair,
as it does PenClose.

With Alt key toggles to Smooth Tool on, the Pencil shows the Smooth
cursor while Alt would smooth: with Alt held before a stroke begins to
draw, and through a smoothing drag even after Alt is let go. Before, it
showed the Smooth cursor whenever Alt was held, also during a stroke
that kept drawing, and not during a smoothing drag once Alt was let go.

Docs: docs/target-app-parity.md, crates/tools/src/cursors.toml.
2026-10-21 16:55:17 +07:00
dotCooCoo bcf8732d67 Artboards: Rename gives several selected artboards one name, numbered
With several artboards selected with the Artboard tool, the Artboards
panel menu's Rename opened the name field on the active artboard's row
and renamed that artboard only.

Rename now opens an empty name field on the active artboard's row, and
so does a double-click on a name with Shift or Ctrl/Cmd held that
leaves several artboards selected. The name typed there goes to every
selected artboard, numbered in panel order (abc-1, abc-2, abc-3), in
one undo step through the artboards list of artboard.setProps. With
one artboard selected, Rename and a double-click on a name rename that
artboard as before.

Docs: docs/target-app-parity.md.
2026-10-21 16:55:17 +07:00
dotCooCoo 2c4d0bac92 Brushes: colorization recolors type in scatter brush art
A scatter brush whose art held type drew the type in its own colors
whatever the brush's colorization: Tints, Tints and Shades and Hue
Shift recolored the art's paths but not its characters. The Key Color
list in Brush Options left the type's colors out as well.

Colorization now maps the solid fills and strokes of each run of type
in the art as it maps the fills and strokes of paths. The Key Color
list, whose first color is Hue Shift's default key, counts each run's
fill, and its stroke when the stroke is wider than 0. The Symbol
Stainer now mixes the characters' strokes of type toward the stain,
as it already mixed their fills.

Docs: docs/target-app-parity.md.
2026-10-21 16:55:17 +07:00
dotCooCoo 6a1c8eda46 Part of #1816: Snap to Grid lands a moved object by its nearest edge and no longer snaps its center
With View › Snap to Grid on, a whole object moved with the Selection,
Group Selection or Direct Selection tool landed on the grid by
whichever of its two edges or its center was nearest a gridline on
each axis. An object on the grid whose width is an odd number of grid
steps has its center half a step between gridlines, so a short drag
could pull it half a step off the grid: on a 20 pt grid, a 100 pt
square at (100, 100) dragged by (8, 8) moved by (10, 10).

A move now lands the nearer of the two edges on a gridline on each
axis, as the reference app's help describes, and the center no longer
snaps. The square above stays at (100, 100). This changes the rule #769
chose for #740. The plain move and the Shift-constrained one both use
grid_pull, so both change.

Docs: docs/target-app-parity.md (Drawing aids).
2026-10-21 16:55:17 +07:00
HEOJUNFOandClaude Opus 5.5 764d04926e Pick placed images on their frame with Selection by Path Only (#3291)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 16:55:17 +07:00
dotCooCoo 1047c82bd3 Artboard tool: Shift-drag draws an artboard inside another or marquee-selects artboards
With the Artboard tool, a Shift-press on an artboard toggled it in the
selection and started a move, which Shift kept at 45° steps. A
Shift-drag on the pasteboard drew a square artboard. The reference
app's documentation gives Shift-drag inside an artboard as the way to
draw an artboard there, and Shift-drag on the canvas as a marquee that
selects artboards.

A Shift-click now toggles its artboard when the button comes up. A
Shift-drag inside an artboard draws a new artboard (artboard.new), and
a Shift-drag on the pasteboard toggles every artboard it touches, the
last one added becoming active. Shift no longer constrains an artboard
move or keeps a new artboard square, and no other modifier does. Shift
on the active artboard's handles still resizes it proportionally. The
hint bar names the Shift gestures in every complete catalog, and the
cursor is a crosshair over an artboard while Shift is held.

Escape while a new artboard was being drawn left the tool. Escape
during any drag, the new marquee included, now cancels the drag and
keeps the Artboard tool, as it already did during a move or a resize.

Docs: docs/target-app-parity.md.
2026-10-21 16:55:17 +07:00
00xJ0 91107c89d2 PDF: a Document CMYK destination; Press Quality converts to it in RGB documents too (#3012 review)
A blank destination is the document's own profile, so in an RGB document Press Quality (Preserve
Numbers, blank) converted CMYK colours and images to RGB. Output › Destination can now be
DOCUMENT_CMYK, the CMYK profile of the colour settings in effect whatever the document's colour
mode (Illustrator's "Document CMYK"); Press Quality uses it, and the Save PDF dialog lists it after
Document profile. Test: in an RGB document a CMYK image keeps its inks and an RGB image comes out
CMYK.
2026-10-21 16:55:17 +07:00
00xJ0 5d0b966548 PDF presets: Press Quality converts to the document's profile, keeping its numbers (#2950)
Press Quality left Output at No conversion, so in a CMYK document RGB images went to the press as
DeviceRGB. It now converts to the destination with the numbers kept, the destination blank (the
document's profile): RGB comes out CMYK in a CMYK document, CMYK values stay exactly, RGB
documents are unchanged, and no profiles are included, as a printer's published [Press Quality]
settings list.
2026-10-21 16:55:17 +07:00
dotCooCoo 3cd2ae2f12 Tests: the engine-language test runs a Japanese frame again
the_engine_follows_the_interface_language drew only English frames
after 6efe9df3e, because a Japanese frame set the drawing language for
every test running beside it. It set the engine's language by hand and
never checked that a Japanese frame hands the engine ja. Since #2570
each unit-test thread has its own drawing language.

The test again runs a frame with the interface preference set to
Japanese and checks that the engine gets ja, then runs a frame with
the preference back on auto and checks that it gets en. It also checks
that a thread started after the Japanese frame still draws in English.
With the drawing language process-wide, as before #2570, the test
fails, and the zoom hint test beside it showed its hint in Japanese in
12 of 200 runs.
2026-10-21 16:55:16 +07:00
dotCooCoo 68d8a9c755 Export: area type that Shrink Text to Fit scales down is written at the size it draws at
Shrink Text to Fit scales area type down at layout time, and the canvas,
PDF export and the exports that write type as outlines draw it at that
size. SVG export wrote the live text at its stored font size, letter
spacing and baseline shift, so a viewer drew it larger than the canvas
does. Legacy .ai saves wrote the stored size, leading and baseline shift
into the text objects, DXF export took the TEXT heights from the stored
size, and the CSS Properties panel gave the stored font size, letter
spacing and line height.

The text crate's scaled, which the layout scales shrunk text with, is
now public. SVG export, legacy .ai saves and the CSS Properties panel
take the type's styles from it at the layout's fit scale, and DXF
export multiplies the TEXT heights by that scale. Positions and
textLength in SVG still come from the layout.

Docs: docs/target-app-parity.md.
2026-10-21 16:55:16 +07:00
dotCooCoo b1abc73e16 Isolation mode: object.isolate checks a Shaper Group's source group
object.isolate on a Shaper Group isolates the group's source group, but
the hidden and locked check ran on the Shaper Group itself, so a source
group hidden or locked with object.setProps was still isolated. The
check now runs on the object that isolation mode enters.
2026-10-21 16:55:16 +07:00
dotCooCoo bdf54007a0 Isolation mode: object.isolate refuses hidden or locked objects and locked guide paths
A click with the Selection tool does not select a hidden or locked
object, an object in a hidden or locked group or layer, or a guide path
while Lock Guides is on, but object.isolate isolated each of them. Once
a locked or hidden group, or a group on a locked or hidden layer, was
isolated, a Selection tool drag moved the member under the pointer. The
Layers panel's Enter Isolation Mode sent the same command for a locked
or hidden layer or group.

object.isolate now refuses a hidden or locked object, or one in a
hidden or locked group or layer, with "the object is hidden or locked"
(the text of layer.target), and a guide path while Lock Guides is on
with "guides are locked". Isolation mode stays off, and the selection
stays as it is.

Docs: docs/mcp.md.
2026-10-21 16:46:57 +07:00
dotCooCoo 9aad69b8f6 i18n: rows main now requires for the one-term blend spine
The zh-hans Replace Spine message that #2578 added names the spine 混合轴. It now uses 脊线, the term of the other zh-hans spine rows.
2026-10-21 16:46:42 +07:00
dotCooCoo 63c17ec13e i18n: each catalog names the blend spine with one term
Nine catalogs named a blend's spine with two or more terms across the
rows for the spine commands and the Replace Spine message. Bulgarian
used four (оста, гръбна линия, скелет, гръбнак), and Ukrainian, Greek,
Finnish, Japanese, Korean, Brazilian Portuguese and both Chinese
catalogs used two each.

Each of those catalogs now uses the term its rows used most often:
оста in Bulgarian, вісь in Ukrainian, κεντρική καμπύλη in Greek,
selkäranka in Finnish, スパイン in Japanese, 스파인 in Korean, coluna in
Brazilian Portuguese, 脊线 in Simplified Chinese and 脊線 in Traditional
Chinese. 19 rows change, and no English string changes. A new i18n test
checks that every row naming the spine in the 19 catalogs uses its
catalog's term.
2026-10-21 16:46:42 +07:00
Taha-Mahmoodi d4df4646ee Build: the wasm check is warning-free; one file-dialog check instead of two
- ui-egui: the font-install poll (#2576) only runs on native builds, but its
  timestamp field, its initializer and FONT_INSTALL_POLL were compiled for
  wasm too, where nothing read them. They're gated like the code that reads
  them.
- engine: `Session::store_profile` is only called by the native Load
  Profile (cmd/colormgmt.rs); gated the same way.
- app (macOS): `App::logic_frame` checked `!file_dialog_open()` twice, one
  `if` nested in an identical one, with the comment repeated. One check.

`cargo xtask wasm` runs `cargo check` without `-D warnings`, so these
warnings didn't fail CI; after this change it prints none.
2026-10-21 16:46:41 +07:00
dotCooCoo 0701340720 Layers: Cmd+Alt+L opens Layer Options for a new layer
The reference app adds a layer while opening the New Layer dialog with
Ctrl+Alt+L on Windows and Option+Command+L on macOS. VectorCraft opened
that dialog from an Alt-click on the Layers panel's New Layer button,
from Help › Search Commands… and from the panel menu's New Layer…
item. It had no key for it, and a Cmd+Alt+L press ran New Layer as
Cmd+L does.

ui.newLayer (New Layer Options…) now has the default Cmd+Alt+L, which
is Ctrl+Alt+L on Windows and Linux. The key opens Layer Options for a
new layer, as the Alt-click does: OK adds the layer above the current
layer, and Cancel adds none. Cmd+L still adds a layer without the
dialog. Keyboard Shortcuts now lists the command with its key.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-21 16:46:41 +07:00
dotCooCoo 84833d10ce Blob Brush: Shift keeps the stroke horizontal or vertical
A Blob Brush stroke followed the pointer whatever modifier keys were
held. The reference app's shortcut list has Shift constrain the Blob
Brush path horizontally or vertically.

With Shift held during a drag, each point of a Blob Brush stroke now
goes on the horizontal or vertical line through the press point that
is nearer in angle to the pointer, at 90-degree steps from the
Constrain Angle preference, and keeps its distance from the press
point. When the pointer crosses to the other line, the points dragged
since Shift went down move to it, each on its own side of the press
point. Each point reads the Shift state of its own event, so the
points dragged while Shift is up follow the pointer. The stroke still
runs path.blob once on release.

Docs: docs/target-app-parity.md.
2026-10-21 16:46:40 +07:00
MarekSpodBiedry cde39698ff Draw from centre (Alt) now applies to the typed size of a rectangle or ellipse (#3309) 2026-10-21 16:46:38 +07:00
dotCooCoo b0056a127d Type: Optical Margin Alignment hangs punctuation at the ends of type on a path
Optical Margin Alignment and Roman Hanging Punctuation hung punctuation
in point and area type only. Type on a path kept its first and last
glyphs inside its brackets with either option on.

When Optical Margin Alignment is on, or the first paragraph has Roman
Hanging Punctuation on, the glyphs at the ends of left-to-right type on
a path now hang outside its brackets by the same amounts, measured
along the path. The type hangs at the start bracket when its line is
placed there, hangs at the end bracket when the line is placed there,
and hangs at both when it is centered. Glyphs that hang past the end
bracket do not make the type overflow. On an open path a hang stops at
the path's end, and on a closed path the two hangs together take at
most the stretch between the end and start brackets. SVG export writes
type on a path that hangs as outlines, with a warning.

Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
2026-10-21 16:46:23 +07:00
dotCooCoo 1cea163b11 Paragraph: Insert Kashidas justifies Arabic lines with kashidas
The reference app justifies Arabic text with kashidas, set per paragraph
with the Paragraph panel's Insert Kashidas list (None, Short, Medium,
Long, Stylistic). VectorCraft had no kashidas, so a justified Arabic
line was spaced out at its word spaces like a Latin one.

The Paragraph panel, with the Indic options shown, now has Insert
Kashidas. On a justified horizontal area type line that is shorter
than its measure, each Arabic word with joined letters gets one kashida
between its last pair of them, drawn as tatweels (U+0640) in the font
of the letter after it. The kashidas share the line's room equally,
each at most a quarter of an em (Short), half an em (Medium) or an em
(Long); a Stylistic kashida is a row of the font's own tatweels, up to
an em. The word spaces take the room the kashidas leave, so the line
keeps its width, and line breaks do not change. The letter after a
kashida advances over it, so the caret before that letter, a selection
of it and a click on it take the kashida in. None, the default, leaves
lines as they were.

In horizontal type that is not on a path, the caret in a letter drawn
with several glyphs (a letter with a vowel mark) is now placed by the
whole letter, as hit testing already measured it, instead of by its
first glyph.

text.setFormat takes kashida. The setting is saved with the paragraph
only when it is not None, paragraph styles carry it, and Reset Panel
sets it back to None.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 16:46:22 +07:00
dotCooCoo 3677a16474 Type: the Every-line Composer ends a line after the spaces that follow it
The composer offered a break after a dash, a Thai word or a space even when
a space came next, so it could start the next line with that space. A
leading space makes the next line wider, and the composer could score that
break better. With mixed type sizes now composed in rectangular area type,
Thai text whose words come from a Thai font broke as "สวัสดีครับ" and
" ภาษาไทยง่าย". The composer now offers only the break after the spaces,
as the single-line composer ends its lines.
2026-10-21 16:46:19 +07:00
dotCooCoo ae41ddb9a5 Undo groups: library styles and field scrubs close only the group they open
Using a library's character or paragraph style, and scrubbing a
numeric field, closed their undo step with Session::end_undo_group,
which closes the open undo group of every document. An undo group open
in another document, such as one begun with edit.undoGroup.begin for
an MCP command_batch with atomic, was closed too, and that document's
next edits became undo steps of their own. When the style failed to
apply, or Escape ended the scrub, the other document's group was
canceled as well and the edits made in it were undone. Inside an undo
group already open in the same document, a scrub closed that group,
Escape undid the edits made in it before the drag, and a style that
failed to apply left the style it had added.

Session::begin_undo_scope and end_undo_scope make the edits in between
one undo step of the active document: the scope opens an undo group
there, or joins the one already open. Ending a scope closes only the
group it opened, in its own document, even when another document
became active in the meantime, and a canceled scope undoes only its
own edits, also inside a group that was already open. library.use and
the scrub use the scope, so Escape inside an open group also takes
back a scrub of the Transform panel's Shear field. edit.undoGroup.end
still closes the open group of every document.
2026-10-21 16:46:19 +07:00
dotCooCoo 9be3ddab31 Type: the Every-line Composer composes paragraphs that mix type sizes in rectangular area type
A paragraph with one character in a different type size was broken
with the Single-line Composer, even when Every-line was chosen: the
composer gave up whenever the paragraph's glyphs did not all have the
same line metrics (ascent, descent, leading, cap height, x height and
em box top).

In rectangular area type without text wrap, rows and columns included,
the width of a line does not depend on how far down the frame it is.
There the Every-line Composer now composes a paragraph whose
characters differ in line metrics (type size, font, leading or
vertical scale, or an inline graphic taller than the text), and each
line takes its height from its own text, as with the Single-line
Composer. Area type of another shape, or with text wrap, still breaks
such a paragraph with the Single-line Composer.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 16:46:19 +07:00
dotCooCoo 5846b00da3 Actions: a recording keeps its steps when a canceled undo group drops earlier entries
When a cancel removes journal entries numbered below a recording's
start, the entries after them are numbered down. The recording's start
and the journal's hold kept the old number, so Stop Recording left out
as many of the recorded steps as there were entries removed, and the
hold no longer covered them. `Journal::remove` now moves the hold down
with the entries, and Stop Recording reads the start from the hold.

`Journal::replace` puts an entry in place of another and counts it at
its new size. A cancel uses it for the `document.activate` that takes
the place of a canceled group's entry that switched documents, so the
journal's size estimate does not keep the old entry's size.
2026-10-21 16:46:18 +07:00
dotCooCoo 1565c96123 Undo groups: a cancel keeps the journal entries made outside the canceled groups
A cancel cut the journal back to where the first canceled group began.
That also removed the commands run in other documents after it began,
which the cancel does not undo, so a replay of the journal lost them.

Each undo group now keeps the numbers of its own journal entries: the
commands that start and end in its document and the commands that edit
it. A cancel removes only those entries, and the later entries are
numbered down to close the gaps (`Journal::remove`). An entry of a
canceled group that also switched to another document, such as a batch
that draws and then activates another document, is replaced by a
`document.activate` of that document, so the commands after it replay
in the same document.
2026-10-21 16:46:18 +07:00
dotCooCoo 1613ef99f6 Undo groups: a cancel drops the edits of every canceled group from the journal
Session::end_undo_group with cancel cancels the open undo group of
every document. edit.undoGroup.end with cancel calls it, and so do a
field scrub ended with Escape and a library style that fails to apply.
The journal was then cut back to where the group of the last document
in the document list began, not to where the first of the canceled
groups began. When the group that began first belonged to an earlier
document, the journal kept the commands of that group's edits, which
the cancel had undone. After a scrub ended with Escape, which journals
no end command, replaying the journal drew those edits again.

Session::end_undo_group now cuts the journal back to the earliest start
of the groups it cancels.
2026-10-21 16:46:18 +07:00
dotCooCoo 819efd465f EPS import: a PostScript error shows what its name means in the interface language
An EPS or PostScript file that stops on a PostScript error named the
error, such as typecheck or rangecheck, with the operator and the
procedure, but did not say what the name means in any language.

The warning or error now gives the name as PostScript spells it,
followed by VectorCraft's own wording of what it means in the reader:
"a PostScript error (typecheck in `add`; a value has the wrong type for
the operator)". vectorcraft_eps::POSTSCRIPT_ERRORS lists the 14 names
the reader raises other than undefined, with their meanings, and a
test checks that each of those names has one. Each meaning and the
new message shapes have a row in every catalog. i18n::message now
translates placeholder values five levels deep instead of three, which
the status bar's line for an EPS file that can't be opened needs.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 16:46:17 +07:00
dotCooCoo d55a5245fb Graphs: column designs show each column's value where their type has % and two digits
The reference app's graphs help describes adding totals to a column
design: type in the design holds a percent sign followed by two digits.
VectorCraft drew that type as typed, stretched with the design.

Type in a column design that holds a % followed by two digits now shows
the value of each column or stacked segment in place of the % and the
two digits. The first digit is the least number of places before the
decimal point, with zeros added in front (0 for as many as the value
needs), and the second is the number of places after it. Values are
rounded half away from zero. The type keeps its own size and moves to
where the design's copy puts the point it is set from. A repeating
design shows it once per column, where a whole copy ending at the value
end puts it, and Chop Design does not cut it. A sliding design moves it
with the part of the design on its side of the guide. Type inside a
group that clips or has its own opacity, blend mode, appearance or
opacity mask, or inside a blend, compound shape, envelope, repeat or
intertwine, stays in the design and shows the value in each copy. Type
inside a symbol stays as typed. The legend swatch leaves the totals
out.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 16:46:15 +07:00
dotCooCoo 96592e9e27 Tabs: in vertical type the panel names its alignments Top-Justified and Bottom-Justified Tab and labels the position Y
The Tabs panel named its alignment buttons Left-, Center-, Right- and
Decimal-Justified Tab and labeled the position field X for every text.
The reference app's alignments for vertical text include Top-Justified
Tab and Bottom-Justified Tab, and its position field is Y for vertical
text.

In vertical type the Left-Justified button is now named Top-Justified
Tab and the Right-Justified button is named Bottom-Justified Tab, in
their tool tips and for screen readers, and the position field is
labeled Y. The reference page does not say which buttons these are;
VectorCraft's own mapping follows the line direction: a line of
vertical type runs from top to bottom, so a Left stop aligns the text
to the top and a Right stop aligns it to the bottom. Screen readers now
call every alignment button by its name; before, they read its glyph.
In horizontal type the tool tips and the X label do not change.

Docs: docs/target-app-parity.md.
2026-10-21 16:46:14 +07:00
dotCooCoo 6041bb3c62 Objects on Path: clipping paths and parts of blends, envelopes and repeats stay in place
object.objectsOnPath.attach took a path inside a compound path, blend,
envelope, repeat or Intertwine group, or a clipping path, as the path
and put it into the new Objects on Path group, so it was no longer a
part of its container. As objects, a blend's key path, a repeat's
source art, an envelope's content and a clipping path were put into
the new group the same way.
The Objects on Path tool highlighted a blend's key path or a clipping
path and attached the selection to it.

objects_on_path::held now finds the container that holds an object.
The command refuses such a path or object with a reason, and the tool
does not highlight it or attach to it. A member of a compound path
given as an object still attaches as its whole compound path. The four
new messages have rows in every complete-message catalog.

Docs: docs/mcp.md (Objects on Path).
2026-10-21 16:46:13 +07:00
dotCooCoo 54ee0b6251 Swatches: a swatch made from a color the app shows keeps that color's model in a CMYK document
Since #2664, swatch.new separates an RGB color into CMYK through the
color settings in a CMYK document unless keepModel is given. The Color
Guide, Color Themes and Recolor Artwork send keepModel when they save
colors they show, but the Color panel menu's Create New Swatch, the
Swatches panel and the Gradient panel did not. An RGB object's color
saved from them became a CMYK swatch, and a gradient's RGB stops
became CMYK stops.

These now send keepModel: Create New Swatch, an Alt-click on New
Swatch, the New Swatch dialog for a gradient, a Fill or Stroke proxy
or the Gradient panel's thumbnail dropped on the Swatches panel or
Alt-dropped on a swatch of its kind, and the Gradient panel's Add to
Swatches and Save to Swatches. A new swatch_paint_params returns
paint_params with keepModel: true added for an unlinked color or a
gradient. swatch.new without keepModel still converts RGB input.

Docs: docs/target-app-parity.md.
2026-10-21 16:46:13 +07:00
dotCooCoo 67448e9245 Live Paint: Merge keeps the group's lock, visibility, mask and graphic style link
Object › Live Paint › Merge built the merged group anew and copied
only the bottom Live Paint group's name, opacity and blend mode, so
its lock, visibility, appearance, opacity mask, graphic style link and
other properties were lost. Make with a Live Paint group selected, and
a Live Paint Bucket click on a selected path beside a selected Live
Paint group, made a new group with a new id instead, and every face
took the fill of its paths again.

Merge now starts from the bottom Live Paint group, as Gap Options
does, and replaces only its faces, edges and paths and its Gap
Options. Make, and livePaint.fill with ids when the point is outside
that group's faces, add the other objects to the bottom Live Paint
group among them as Merge does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 16:46:12 +07:00
dotCooCoo 0ec23d90bc Live Paint: the Live Paint tools skip hidden edges
An edge hidden on its own (for example with Object > Hide) while its
Live Paint group stays visible could still be highlighted, selected or
stroked, and a hidden edge nearer the pointer kept a visible one from
being picked.

lp_part_at now takes the nearest visible edge (visible_edge_near), inside
and outside the faces, for the hover, the Live Paint Selection click and
the Bucket's Shift-click. The Bucket's Shift-click sends nothing where no
edge is highlighted, and livePaint.strokeEdge also skips hidden edges, so
the Bucket strokes the edge it highlights.
2026-10-21 16:46:11 +07:00
dotCooCoo ed93963d73 Live Paint: a Live Paint Selection click outside the faces selects the edge it highlights
With Select Fills off, or with Alt held, the Live Paint Selection tool
highlighted an edge near the pointer even when the pointer was outside
every face, but a click there looked only for a face under the pointer
and cleared the selection. Outside the faces, the highlight came from
the front-most Live Paint group with an edge in reach, not from the
nearest edge, and it included hidden groups. The Live Paint Bucket's
Shift highlight and Shift-click used the same search.

The hover, the Live Paint Selection click and the Bucket's Shift-click
now call one function. Outside every face it picks the edge nearest
the pointer, within 4 screen pixels, among the visible Live Paint
groups. A click selects or strokes the edge that the highlight shows.

This change follows up #3052.
2026-10-21 16:46:11 +07:00
Jürg Lehni 3f729b6481 FilePick filters own their text, so a dialog's file types can be made at run time: FileFilter is (String, Vec<String>), FilePick::filter makes one from literals and FilePick::filters converts the engine's lists; the macOS save panel, the Linux portal and the rfd dialogs take the owned lists. No behaviour changes. 2026-10-21 16:46:10 +07:00
BFlat 5b77e422ab Merge pull request #3181 from danngreen/feat/distribute-with-guides
Distribute: selected ruler guides are spaced with the objects
2026-10-21 16:19:52 +07:00
BFlat bc441d102e Merge pull request #3246 from addibble/fix/clip-path-outline-hit
G1: selection tools take a clipping path on its outline
2026-10-21 16:19:37 +07:00
BFlat 6ee04f3805 Merge pull request #3215 from ashsajal1/main
G1: a move over a panel waits and resumes on the canvas (#2826)
2026-10-21 16:19:19 +07:00
BFlat a2abc1316b Merge pull request #3265 from dotCooCoo/fix/tabs-selection-follows-sorted-stop
Tabs: a stop moved past another stays selected
2026-10-21 16:19:01 +07:00
BFlat 61f278f894 Merge pull request #3299 from dotCooCoo/fix/data-set-status-clears-after-applying
Variables: the nothing-applied message clears when a later data set applies
2026-10-21 16:18:46 +07:00
BFlat a09828188a Merge pull request #3335 from dotCooCoo/feat/pattern-scale-slider
Brush Options: Pattern Scale and Art Width have sliders
2026-10-21 16:18:29 +07:00
BFlat 906da779ff Merge pull request #3340 from dotCooCoo/fix/edit-within-zero
Pencil: Edit selected paths within 0 turns editing off
2026-10-21 16:18:13 +07:00
BFlat f0667469e5 Merge pull request #3189 from AJMakesStuff/3179---Envelope-Distort
(3179) Envelope Distort
2026-10-21 16:17:56 +07:00
BFlat 69e73564d2 Merge pull request #3257 from FractalEncrypt/fix/polygon-star-counts
Persist Polygon and Star counts after exact-size placement
2026-10-21 16:17:38 +07:00
BFlat e2547c933d Merge pull request #3271 from FractalEncrypt/fix/live-trace-bounds
Exclude retained trace bitmaps from result bounds and highlights
2026-10-21 16:17:22 +07:00
BFlat d47346b237 Merge pull request #3316 from odysseexp/fix_svg_gradients
SVG import: support feBlend filter primitive and transparent backdrops
2026-10-21 16:17:05 +07:00
BFlat 94e3cb7bf7 Merge pull request #3333 from dotCooCoo/fix/brush-hueshift-default-key
Brushes: Hue Shift without a key uses the most-used art color
2026-10-21 16:16:53 +07:00
BFlat b29f1394c2 Merge pull request #3330 from dotCooCoo/feat/docinfo-brushes
Document Info: a Brushes category lists the document's brushes
2026-10-21 16:16:41 +07:00
BFlat c2313099d4 Merge pull request #3313 from dotCooCoo/feat/view-show-live-paint-gaps
View: Show Live Paint Gaps highlights the gaps of the selected Live Paint groups
2026-10-21 16:16:29 +07:00
BFlat f8dbbc25b2 Merge pull request #3334 from dotCooCoo/feat/brush-select-unused
Brushes: Select All Unused selects the brushes the document does not use
2026-10-21 16:16:11 +07:00
BFlat 7a1e7c1955 Merge pull request #3337 from dotCooCoo/fix/select-bristle-by-kind
Select: Bristle Brush Strokes selects objects by their brush's type
2026-10-21 16:15:54 +07:00
BFlat 6f3df95ad4 Merge pull request #3339 from dotCooCoo/fix/artboards-panel-convert-to-artboards
Artboards: the panel menu's Convert to Artboards converts the selected objects
2026-10-21 16:15:42 +07:00
BFlat 1b5d8abdcf Merge pull request #3324 from dotCooCoo/feat/new-document-without-dialog-key
File: Cmd+Alt+N makes a new document without the New Document dialog
2026-10-21 16:15:31 +07:00
BFlat 8d1bbbf35e Merge pull request #3341 from dotCooCoo/feat/appearance-new-fill-and-stroke-keys
Appearance: Cmd+/ adds a new fill and Cmd+Alt+/ adds a new stroke
2026-10-21 16:15:14 +07:00
BFlat 96fe96de77 Merge pull request #3205 from nrq6993-a11y/stream/vc-feat1-g1-anchor-point-move-measure
G1: Anchor Point tool shows move distance in measurement labels while Shift-dragging
2026-10-21 16:14:58 +07:00
BFlat 3e93c71772 Merge pull request #3217 from nrq6993-a11y/stream/vc-feat1-g1-reshape-offset-readout
G1: Reshape tool shows Smart Guide offset readouts while reshaping paths
2026-10-21 16:14:42 +07:00
BFlat 52e4fa52c5 Merge pull request #3218 from nrq6993-a11y/stream/vc-feat1-g1-place-file-marquee-size
G1: File Place cursor shows placed size in measurement labels while dragging
2026-10-21 16:14:26 +07:00
BFlat 410fa6b84f Merge pull request #3182 from nrq6993-a11y/stream/vc-feat1-g1-type-area-measure
G1: Area type drag shows width and height in measurement labels
2026-10-21 16:14:08 +07:00
BFlat d65094ac5d Merge pull request #3208 from Taha-Mahmoodi/fix/2820-image-cache-thrash
Fix #2820: images drawn small redraw from their kept mips while being moved
2026-10-21 16:13:49 +07:00
BFlat 47c0374858 Merge pull request #3270 from Silvertree2010/fix/3241-text-stroke
Keep stroke weight in document space on transformed type
2026-10-21 16:13:32 +07:00
BFlat 5fdea8749c Merge pull request #3252 from MarekSpodBiedry/issue-3234
Keep the swatch link when blending two tints of one swatch (#3234)
2026-10-21 16:13:14 +07:00
BFlat 649d3c83db Merge pull request #3200 from Taha-Mahmoodi/fix/3198-fps-readout
Fix #3198: the status bar's fps is the rate the window draws at, not an average of idle bursts
2026-10-21 16:12:55 +07:00
BFlat c2f2a49a32 Merge pull request #3206 from Taha-Mahmoodi/fix/3188-clip-arrange
Fix #3188: arranging art inside a clipping mask keeps the clipping path at the bottom
2026-10-21 16:12:43 +07:00
BFlat 5a12b47e4d Merge pull request #3269 from MiQ71/fix/issue-3219-show-hide-guides
Fix Show and Hide Guides (#3219)
2026-10-21 16:12:27 +07:00
BFlat 9674310a58 Merge pull request #3307 from jordanlemcc-ship-it/fix/hook-ime-backspace-reinject-order
Fix lost diacritics with keyboard-hook IMEs (UniKey/OpenKey)
2026-10-21 16:12:09 +07:00
BFlat b9a37cbd05 Merge pull request #3321 from dotCooCoo/fix/links-on-another-mapped-share-are-not-followed
Links: a link on a drive mapped to another share is not followed
2026-10-21 16:11:54 +07:00
BFlat 69890c37ff Merge pull request #3344 from dotCooCoo/fix/save-for-web-private-preview-folder
Save for Web: browser previews go only to a folder the current user owns
2026-10-21 16:11:38 +07:00
Dan Green e8bd0ac410 Distribute: selected ruler guides are spaced with the objects (#1597 in part)
Distribute Objects and Distribute Spacing space the selected ruler
guides across the axis (vertical ones for the horizontal options,
horizontal ones for the vertical options) together with the selected
objects, as items with no size. Guides along the axis stay where they
are. Two or more guides selected without objects are enough, and the
Align panel's Distribute buttons are enabled for them. With Align to
Artboard, the outermost items go to the artboard's edges.

A key guide (select.key {guide}) is one of those items. With a spacing,
it stays put and the other items are spaced from it, as from a key
object with no size; a key guide along the axis leaves everything where
it is. The Align panel sends the Distribute Spacing value with both
rows of Distribute buttons while a guide is the key, and enables the
value for it.
2026-10-21 15:44:27 +07:00
Drew DibbleandClaude Opus 5.5 5fd553cc8b G1: loaded text, double-clicks and guide release take what is drawn
The selection tools' clipping-path outline hit was also used where the
Selection and Direct Selection tools act on what is drawn rather than
select. Near a layer's clipping mask edge, with art drawn over it:
loaded text threaded into the unpainted mask instead of the art, a
double-click on type entered isolation on the mask instead of editing
the type (Selection) or did nothing (Direct Selection), and Cmd/Ctrl+
Shift double-click could not release a guide path there. These now
take the ordinary hit (ToolContext::hit_options); a press or hover
that selects keeps the outline hit.

Tests (each fails with the selection hit options):
select::tests::loaded_text_threads_into_the_art_on_a_layer_masks_edge,
a_double_click_on_type_at_a_layer_masks_edge_edits_it (Selection and
Direct Selection) and a_guide_on_a_layer_masks_edge_is_released, over
the new testutil helpers add_masked_layer and
doc_with_type_on_a_layer_masks_edge. Recorded in
docs/target-app-parity.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 15:44:27 +07:00
Ashsajal 923c49eda0 G1: a touch takeover drops a move waiting over a panel (#2826)
A second finger, a long touch or a cancelled contact replaced Drag::Art
with Drag::Gesture without ending the live Move interaction, which then
outlived the pointer: later edits began on top of an open interaction.
drop_press now cancels the move for Drag::Art too and clears the panel
payload, as it already did for Drag::Tool.

Test: a_second_finger_drops_a_move_waiting_over_a_panel.
2026-10-21 15:42:37 +07:00
dotCooCoo a304cdb589 Tabs: a refused tab stop move leaves the ruler as it was
When text.tabs.set refused a new X (past 100,000 pt), the panel kept
the stops it had changed and sorted, so later in the same frame the
ruler painted the refused position and highlighted another stop.
apply_moved now moves and sorts a copy of the stops and keeps it only
when the stops are set.
2026-10-21 15:42:37 +07:00
dotCooCoo 5660a15b75 Variables: the nothing-applied message clears when a later data set applies
After "nothing in data set A was applied: no object is bound to a
variable it has a value for" showed in the status bar, choosing a data
set that did apply, or stepping to one, left that message in place.
`run_now` set the status from a command's `warning` and cleared it only
for `file.new`, and the Variables panel test cleared the status by hand
before its last check.

`dataset.select`, `dataset.next` and `dataset.prev` now clear the status
bar when they succeed, and the warning shows when no object is bound to
a variable the new data set has a value for. The test no longer clears
the status by hand and checks all three commands.
2026-10-21 15:42:37 +07:00
dotCooCoo 14ec7f472f Brush Options: Pattern Scale and Art Width have sliders
Pattern Brush Options' Scale and Art Brush Options' Width were number
fields only. The reference app's Brushes page documents a Scale slider
for pattern brushes and a Width slider for art brushes.

Each row now has a slider beside its field, as the Bristle Brush
Options sliders do. The slider covers the value's whole range, 1% to
10000% for Scale and 1% to 1000% for Width, in whole percents. Screen
readers name the slider as its row and read the field's number and
text.

Docs: docs/target-app-parity.md.
2026-10-21 15:42:37 +07:00
dotCooCoo ba8c1e042c Pencil: Edit selected paths within 0 turns editing off
docs/control-protocol.md and the tool.options params text say that 0
turns the Pencil and Paintbrush options Edit selected paths within and
Close paths when ends are within off. A stroke that
started exactly on an end of a selected open path still continued that
path at 0, and a stroke that ended exactly where it started was still
closed at 0, because both checks compared the distance with <= 0.

At 0, a stroke that starts on an end of a selected open path now draws
a new path, as one that starts on the path away from its ends already
did, and a stroke that ends where it started stays open.

Docs: docs/target-app-parity.md.
2026-10-21 15:42:37 +07:00
Alexander Richardson 53569a883a [Bug Report] [VectorCraft] Envelope Distort 2026-10-21 15:42:37 +07:00
FractalEncrypt b9fa08f9e6 UI.P1: Persist Polygon and Star counts after exact-size placement 2026-10-21 15:42:37 +07:00
FractalEncrypt a5a9371979 TRACE.B1: Consolidate M12 roadmap rows while retaining distinct trace details 2026-10-21 15:42:37 +07:00
Drew DibbleandClaude Opus 5.5 89b749b828 Contributors: credit addibble by their GitHub profile name
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 15:42:37 +07:00
Ashsajal fbdfe5fc82 G1: a move over a panel waits and resumes on the canvas (#2826)
The Selection tool ended its Move interaction as soon as the pointer left
the canvas, so the art jumped back to where it started while the pointer
was over a toolbar or panel. As #2826 describes, the art should wait where
it last moved and the move should continue when the pointer returns.

Keep the interaction live while the pointer is off the canvas: Drag::Art
holds the art where it last moved, and coming back clears the panel-drop
payload and sends a Drag so the move goes on with the pointer. A release
over a panel ends the move before the panels draw (release_over_panel,
called from ui_frame), so a drop that takes art still gets it as it was
and no interaction is left open.

Tests: art_moved_off_the_canvas_waits_and_drops_on_the_panel,
art_moved_off_the_canvas_resumes_when_the_pointer_returns and
a_move_released_over_a_panel_ends_before_the_panels_draw cover the canvas
states; a_move_dragged_onto_a_panel_reads_the_menus checks the full app
waits over a panel and ends on release. cargo xtask ci: all 10 steps pass.
2026-10-21 15:42:37 +07:00
dotCooCoo a810044e5a Tabs: a stop moved past another stays selected
A tab stop moved past another stop in the Tabs panel, by a drag on the
ruler or by a value typed in X, lost the selection. The panel sorts the
stops by position after the move but kept the selected index, so the
ruler highlighted another stop and the X, Leader and Align On fields
showed and changed that stop. With stops at 30 and 50 pt and the 50 pt
stop dragged to 10 pt, the panel selected the stop at 30 pt.

After a drag or an X value re-sorts the stops, the panel now selects
the moved stop at its new place, found by its position as a click that
adds a stop does. When text.tabs.set refuses the new position (an X
past 100,000 pt), the stops and the selection stay as they were.

Docs: docs/target-app-parity.md.
2026-10-21 15:42:37 +07:00
Drew DibbleandClaude Opus 5.5 061e905724 G1: selection tools take a clipping path on its outline
A clip group answered a press only strictly inside its clipping path,
and there the art above the path came first in the hit test, so where
the clipped art extends past the mask's edge the clipping path's
segments could not be reached on the canvas.

With the new HitOptions::clip_outlines, which the Selection, Direct
Selection and Group Selection tools set
(ToolContext::selection_hit_options), a press within the selection
tolerance of a clipping path's outline, inside or just outside it,
hits the clipping path (HitKind::Outline) before the art it clips:
Direct Selection takes its segments and the Selection tool the clip
group. It applies to a clip group's clipping path and to a layer's
clipping mask, and only to one a click may hit (may_hit: visible,
editable, within reach). Inside the mask the art is hit as before.

The other tools, which act on what is drawn (the eyedropper, type,
gradients...), leave the option off, and hit_test_visible never
applies it, so they still take the art at the mask's edge rather
than the mask's empty appearance. Select Behind's one-pass walk takes
a layer mask's outline first too, so it lists what the repeated hit
test finds. Object Highlighting and a right-click's selection in the
canvas (selection_hit_at) take what those tools take; dropping a
swatch on art still hits what is drawn.

The reference app reshapes a clipping path with Direct Selection:
https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html

Tests in doc: a_clipping_path_is_hit_on_its_outline_before_its_art,
only_the_selection_tools_hit_a_clipping_path_on_its_outline and
select_behind_takes_a_layer_masks_outline_first; the random Select
Behind test now clips layers and sublayers and sets clip_outlines,
and fails without the Select Behind change. In tools,
a_press_on_a_clipping_path_takes_its_segment_over_the_art fails
without the change; select::a_press_on_a_masks_edge_selects_its_clip_group
fails when the Selection tool uses the plain options; the eyedropper
copies the art on the edge. Recorded in docs/ui-parity.md,
docs/gaps.md and docs/target-app-parity.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 15:42:37 +07:00
Harley Hawk de3b94804d Contributors: credit odysseexp by their GitHub profile name 2026-10-21 15:42:36 +07:00
dotCooCoo 787592ff0e Brushes: Hue Shift without a key uses the most-used art color
brush.new and brush.options refused a Hue Shift colorization given
without a key color ("invalid brush: missing field `key`"), and only
Brush Options picked a default. The reference app's Brushes page says
that by default the key color is the most prominent color in the art.

A Hue Shift colorization without a key now takes the solid color that
the art's fills and strokes use most, the first one found on a tie, or
black when the art has no solid color, as Brush Options already does.
Brush definitions read from a document get the same default.

brush.options replaced each field its params named, so an edit that
gave only the colorization method or only an art brush's start guide
was refused because the key or the end guide was missing. It now
merges colorization and an art brush's scale field by field.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo 1e4ebba1cc Document Info: a Brushes category lists the document's brushes
Document Info had no Brushes category, and document.info refused
category "brushes". The reference app's Document Info panel menu lists
Brushes after Graphic Styles.

Document Info now has a Brushes category after Graphic Styles. For the
whole document it lists the Brushes panel's brushes. With Selection
Only it lists the brushes that the selected objects' strokes use, each
once. document.info gives the names as brushNames, the panel's flyout
offers the category, and the text report that Document Info › Save…
and File › Package write has a BRUSHES section.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo d80024eff6 View: Show Live Paint Gaps highlights the gaps of the selected Live Paint groups
The reference app's View menu has Show Live Paint Gaps, which
highlights the gaps in the selected Live Paint group based on the gap
options set for that group. VectorCraft had no such item.

View > Show Live Paint Gaps (view.livePaintGaps) turns the highlight
on or off, and the item reads Hide Live Paint Gaps while it is on.
While it is on and edges show, the canvas draws the gaps of each
selected Live Paint group up to its Paint Stops At size, the gaps that
livePaint.info lists. It draws them as the Gap Options preview does,
as lines in the group's Gap Preview Color. The setting works as View >
Hide Text Threads does: one setting covers every document, it is saved
with the interface state, and Hide Edges hides the gaps. It is off by
default.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo 7fbd215e97 Brushes: Select All Unused selects the brushes the document does not use
The reference app's Brushes panel menu has Select All Unused, which
selects the brushes that are not used in the document. VectorCraft's
Brushes panel menu had no such item, and no command listed the unused
brushes.

brush.unused (Select All Unused) is a query that lists, in library
order, the brushes no stroke paints with. Strokes in the art and its
opacity masks, in symbols, graph designs, pattern tiles and graphic
styles count, and so do strokes in the art of a brush that is used.
The art of a brush that is not used does not count, so a brush whose
own art is the only art that paints with it is listed, and so is a
brush used only in the art of an unused brush. The Brushes panel
menu's Select All Unused selects these brushes, and Delete Brush and
Duplicate Brush then act on them.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo 4bcd14c9c2 Select: Bristle Brush Strokes selects objects by their brush's type
Select > Object > Bristle Brush Strokes selected the objects whose
brush name contains "bristle". It missed objects with a bristle brush
of any other name, and it selected objects whose brush is of another
type with "Bristle" in its name.

select.object.bristleBrushStrokes now looks up the brush of each
object's topmost stroke in the document's brush library and selects the
object when the brush is a bristle brush, whatever its name.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo 6bce3578ca Artboards: the panel menu's Convert to Artboards converts the selected objects
The Artboards panel menu showed Convert to Artboards disabled whatever
was selected, and choosing it did nothing, although Object › Artboards
› Convert to Artboards runs artboard.convertToArtboards on the
selection.

The panel menu's item now uses the same check as Object › Artboards ›
Convert to Artboards, so it is enabled when something is selected, and
it runs the same command. Each selected object that has an area becomes
a new artboard of its geometric bounds and is removed, in one undo step.

Docs: docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo 156bd632e6 File: Cmd+Alt+N makes a new document without the New Document dialog
The reference app makes a document without opening the New Document
dialog with Ctrl+Alt+N on Windows and Option+Command+N on macOS.
VectorCraft had no default key for it.

file.newWithoutDialog (New Without Dialog, default Cmd+Alt+N) makes a
document with the settings New Document opens on: the first Recent
entry, or the Print category's Letter while Recent is empty, named
after the next untitled document. It fills the fields with the
dialog's own open, runs file.new with the params Create sends, and
does not show the dialog. A dialog that is already open stays open.
Keyboard Shortcuts lists it under File.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
dotCooCoo f43c6ff47d Appearance: Cmd+/ adds a new fill and Cmd+Alt+/ adds a new stroke
The reference app adds a new fill with Ctrl+/ on Windows and Command+/
on macOS, and it adds a new stroke with Ctrl+Alt+/ on Windows and
Option+Command+/ on macOS. VectorCraft had Add New Fill and Add New
Stroke on the Appearance panel, with no default key for either.

appearance.addFill now has the default Cmd+/ and appearance.addStroke
has Cmd+Alt+/, which are Ctrl+/ and Ctrl+Alt+/ on Windows and Linux.
Like the panel's buttons and menu items, each key runs its command with
no params and adds the item to every selected object. Keyboard Shortcuts
lists both under Window › Appearance with their keys, and press_key
runs them for agents.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-21 15:42:36 +07:00
nrq6993-a11yandCursor 5ebef8282a G1: Anchor Point Shift-drag shows move distance in measurement labels
Co-authored-by: Cursor <[email protected]>
2026-10-21 15:42:36 +07:00
nrq6993-a11yandCursor 87686e81f2 G1: Reshape tool shows offset readouts while dragging paths
Honor Show Measurement Labels with dX/dY labels during reshape previews.

Co-authored-by: Cursor <[email protected]>
2026-10-21 15:42:36 +07:00
nrq6993-a11yandCursor 32834a9135 G1: Place drag test disables measurement labels for marquee-only overlay check.
testutil: :cx defaults measurement_labels on, so the aspect-ratio drag assertion must not expect a lone Marquee overlay.
Co-authored-by: Cursor <[email protected]>
2026-10-21 15:42:36 +07:00
FractalEncrypt a994f1e416 TRACE.B1: Place the trace bounds milestone note under Image Trace 2026-10-21 15:42:36 +07:00
Harley Hawk 656562d114 SVG import tests: remove machine-specific fixture check 2026-10-21 15:42:36 +07:00
nrq6993-a11yandCursor 2927ff6e85 G1: Place tool shows placement size in measurement labels while dragging.
Honor Show Measurement Labels on the file Place cursor so sizing drags get W/H readouts like other draw tools.

Co-authored-by: Cursor <[email protected]>
2026-10-21 15:42:36 +07:00
FractalEncrypt 240b0ca881 TRACE.B1: Exclude retained trace bitmaps from result bounds and highlights 2026-10-21 15:42:36 +07:00
Harley Hawk cc0e7c4c67 SVG import: support feBlend filter primitive and transparent backdrops 2026-10-21 15:42:36 +07:00
nrq6993-a11yandCursor e76a9c1a45 G1: Area type drag shows W/H in measurement labels
Honor Smart Guides › Measurement Labels while drawing an area-type frame with the Type tool.

Co-authored-by: Cursor <[email protected]>
2026-10-21 15:42:35 +07:00
Taha-Mahmoodi 4e0cbfeede Fix #2820: images drawn small redraw from their kept mips while being moved
With image mips on (the canvas always sets them), drawing an image
fetched its full decoded pixels first, decoding them again when the
shared image store no longer held them, and only then looked up the mip
it draws. The full pixels are only peeked, so they are the least
recently used entries and the first the store drops over its budget
(1 GiB on macOS and Windows). Once a document's full-size images exceed
it (20 photos of 6000 x 4000 px are 1.9 GB as RGBA), every frame decoded
every image again, though only their small mips were drawn: moving one
image redrew seconds later.

The store now remembers the size each blob's pixels decode to, so the
mip level is found without the pixels, and a mip the store holds is
drawn (and marked used) without fetching or decoding them. Without the
mip, at full size, or with mips off (exports) drawing is unchanged.

Measured on an Apple M4 (load average 15-24 from other work), 20 noise
PNGs of 6000 x 4000 px fitted in a 2880 x 1800 view, one moved 5 pt per
frame: before, 20 decodes and 1.1-3.0 s every frame; after, the first
frame decodes 20 and the later ones none, in 3-5 ms.
2026-10-21 15:42:35 +07:00
NetterSilverandClaude Haiku 5.5 4444450a44 Keep stroke weight in document space on transformed type
Fixes #3241

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 15:42:35 +07:00
MarekSpodBiedry 92a9b91255 Keep the swatch link on gradient stops resampled for a blend (#3234) 2026-10-21 15:42:35 +07:00
Taha-Mahmoodi fc644b9a9e Fix #3198: the status bar's fps is the rate the window draws at, not an average of idle bursts
The readout was a moving average of 1/dt over any two consecutive frames.
An idle window wakes now and then (a timer, a control request, a repaint
after input) and each wake-up draws two frames about 4 ms apart, the second
one not waiting for vsync because the swapchain has a free image. Every
wake-up pushed the average up by 0.1 x 240 and the idle gap pulled it down
by only 0.1 x 2, so at rest it climbed past the display's refresh rate
(105 fps on a 30 Hz monitor that was drawing 4 frames a second). As soon as
the pointer moved and frames became vsync-paced, it fell back to the real
rate, which looked like a slowdown.

- frame_rate: frames counted over the last second of continuous drawing.
  A gap more than 100 ms longer than the previous frame's own work means
  the window was idle and starts a new count. The readout changes only
  after half a second of drawing, so a wake-up's frames keep the last
  rate. Slow frames still read slow, because their gap is their own work.
- chrome: "– fps" until the window has drawn continuously, instead of 0.

Measured on an Apple M4 driving a 30 Hz 4K display: drawing continuously
reads 30; at rest with a wake-up every 0.5 s it stays 30 (before: 45 -> 105).
2026-10-21 15:42:35 +07:00
Taha-Mahmoodi 4fdb3e1f9b Fix #3188: arranging art inside a clipping mask keeps the clipping path at the bottom
In a clip group or a clipping layer the bottom child is the clipping path.
Object › Arrange moved the selected children with no regard for that slot:
Send to Back (and Send Backward from just above it) put the art under the
clipping path, so the art became the clipping path, lost its role as art,
and the group drew nothing. Bring Forward / to Front on the clipping path
itself would likewise have made art the clip. Layers-panel moves already
keep the clipping path at the bottom (layer.rs, `index.max(1)`); arrange
didn't.

- object.rs `arrange`: in a parent that clips, the clipping path is left
  where it is and the art is arranged within the slots above it (Back goes
  to just above the clipping path, Backward stops there).
- tests: Back, Backward, several objects, and Forward/Front on the
  clipping path in a clip group (with a render check); Send to Back in a
  clipping layer.
2026-10-21 15:42:35 +07:00
Mirko Kerezovic d2625ec108 Fix remaining Hide Guides rendering cases (#3219)
Include guide visibility in effect raster cache inputs, skip hidden guide paths in outline members, and clear selected guide paths when hiding guides. Add regression coverage for cached effects and selection overlays.\n\nThe cached-effect regression test is adapted from the overlapping #3264 implementation.\n\nCo-authored-by: Mahtab <[email protected]>
2026-10-21 15:42:35 +07:00
jordanlemcc-ship-itandClaude Opus 4.8 0782248cff ui-egui: surface erase errors in the batched type_text path
Address CodeRabbit review on #3307: the new `Seq::Erase` arm discarded
the `tool_key` Result, dropping an erase error silently, whereas the
old `route_type_input`/`text_key` path reported it via `app.status`.
Mirror that behaviour so a failed Backspace/Delete still surfaces in
the status bar. Behaviour otherwise unchanged; fmt/clippy clean and the
three IME tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-10-21 15:42:35 +07:00
dotCooCoo 6e4fb6e4e7 Links: a link on a drive mapped to another share is not followed
craft-docpath follows a stored path only on this computer or on the
share the document is on, but its check is lexical and cannot see
mapped drives. fileio::document_path therefore followed a link stored
as Y:\img\logo.png, where Y: is mapped to \\server\other, from any
document: opening the document, links.check, Link Info and the
linked-file watch read or stamped the file on that server.

On Windows, document_path now reads the final path of the root folder
of a link's drive (std::fs::canonicalize). Windows opens that root
folder on the drive's server to answer, so the check still contacts
the server once per lookup; no file on it is read, stamped or watched.
When the final path names a share, the link is checked against the
document's path on its share, and a link on another share is refused
as on another computer, as a link stored with that share's own path
is. Links on this computer's drives and on the document's share are
followed as before, and a link on the document's own drive is not
looked up. A document opened as \\?\Z:\... is looked up on its share
as Z:\... is. Other systems are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 15:42:35 +07:00
dotCooCoo 82a78e3d58 Save for Web: browser previews go only to a folder the current user owns
Preview in Browser wrote each preview into `VectorCraft Save for Web`,
and before each preview it removed every file and folder there that
was an hour old or older. Where the temporary folder is the shared
/tmp, another user could make that folder first, writable by everyone,
and the app wrote its previews inside it, in folders that user could
read and replace. Where the system follows other users' links in /tmp,
the folder could also be a link, and the app removed the old entries
of the folder it named.

A preview now uses that folder only when it is a folder and not a
link, and on Linux, macOS and FreeBSD only when the current user owns
it and no other user can write to it. On those systems the folder and
each preview's folder are made with mode 0700. The cleanup removes
only preview folders, on those systems only those the current user
owns. When the shared folder can't be used, the preview goes to a new
folder in the temporary folder. A later preview removes such folders
once they are an hour old.

Docs: docs/target-app-parity.md.
2026-10-21 15:42:35 +07:00
MarekSpodBiedry ecd01ad6f1 Keep the swatch link when blending two tints of one swatch (#3234) 2026-10-21 15:42:35 +07:00
Mirko Kerezovic 2305ebb931 Fix Show and Hide Guides (#3219) 2026-10-21 15:42:35 +07:00
jordanlemcc-ship-itandClaude Opus 4.8 17952be94a Fix lost diacritics with keyboard-hook IMEs (UniKey/OpenKey)
Hook IMEs recompose by injecting Backspace key events and VK_PACKET
characters, which egui surfaces as interleaved Event::Text and
Event::Key(Backspace) in one frame. type_text handled text/IME in one
pass and left Backspace to a later pass, so within a frame all text was
inserted before any delete and the diacritics were eaten (tiếng -> tieng).

Take Text, IME and plain Backspace/Delete in a single event-ordered pass,
removing handled keys so route_type_input does not repeat them. Modified
keys and composing frames are unchanged. Adds two canvas IME tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-10-21 15:42:35 +07:00
BFlat 0f39505d12 Merge pull request #3251 from MarekSpodBiedry/issue-3229
Warn on export when a font style is drawn in another style (#3229)
2026-10-21 15:41:57 +07:00
BFlat 31734a0dcb Merge pull request #3285 from dotCooCoo/fix/legacy-ai-radial-gradient-stop-order
Legacy .ai export: a radial gradient's color stops are written from ramp point 0 to 100
2026-10-21 15:41:38 +07:00
BFlat c5f6443e0c Merge pull request #3203 from rahul05ranjan/fix/offset-path-preview-memory
Offset Path's Preview checkbox keeps its last state across opens (#3195)
2026-10-21 15:41:23 +07:00
BFlat 0d387fb16c Merge pull request #3304 from HEOJUNFO/fix/pattern-guides-in-export
Leave the pattern tile edge and swatch bounds out of exports
2026-10-21 15:41:06 +07:00
BFlat 153bcb2ec7 Merge pull request #3284 from MMexicann/fix/css-swatch-stop-precision
M3.57: preserve fractional CSS gradient stop positions
2026-10-21 15:40:50 +07:00
BFlat d5682167ec Merge pull request #3331 from dotCooCoo/fix/user-library-file-panic-is-skipped
Libraries: a User Defined library file whose reading panics is skipped
2026-10-21 15:40:35 +07:00
BFlat 975db53e30 Merge pull request #3286 from lehni/fix/automation-args-timeout
Root-argument tests: give the app 30 seconds to exit, not 5
2026-10-21 15:40:18 +07:00
BFlat a06950b30c Merge pull request #3323 from Silvertree2010/fix/h1183-gpl-palette-with-a-blank-name-li
Keep fallback library name for blank GPL Name line
2026-10-21 15:40:02 +07:00
BFlat a437b9ae63 Merge pull request #3230 from Silvertree2010/fix/h857-measure-tool-shows-angle-0-0-for
Normalise negative zero in Measure tool angle
2026-10-21 15:39:44 +07:00
BFlat ffc7e7272e Merge pull request #3225 from Silvertree2010/fix/h842-xml-id-object-name-round-trip-dr
Encode trailing space as _x20_ in XML ids to keep round trip
2026-10-21 15:39:25 +07:00
BFlat 528f94c4cf Merge pull request #3254 from Silvertree2010/fix/h934-css-font-family-left-unquoted-fo
Quote CSS font-family names that are not plain identifiers
2026-10-21 15:39:08 +07:00
BFlat 0e7cb4afd0 Merge pull request #3240 from Silvertree2010/fix/h876-eps-writer-emits-inf-as-a-number
Fix EPS writer emitting inf for huge finite numbers
2026-10-21 15:38:53 +07:00
BFlat c1a823b34d Merge pull request #2955 from nrq6993-a11y/stream/vc-feat-g2-sketch-water-paper
G2: Effect › Sketch › Water Paper raster filter
2026-10-21 15:28:29 +07:00
MarekSpodBiedry 24638c409b Warn on export when a font style is drawn in another style (#3229) 2026-10-21 15:20:19 +07:00
dotCooCoo bda3c14f72 Legacy .ai export: a radial gradient's color stops are written from ramp point 0 to 100
The version 7 .ai writer wrote the %_Bs color stops of every gradient
from ramp point 100 down to 0. The Adobe Illustrator File Format
Specification version 7.0 lists a linear gradient's stops in that order
(section 1.4.3). Its radial definition, "White & Purple Radial", lists
them from 10 to 100 (section 3.5.2). As reported on #2807, the
reference app reads both stops of #2687's radial gradient, written from
100 down to 0, at ramp point 100 and draws the circle in #1b2a6b. It
reads a radial gradient with three stops written that way in reverse
order.

The writer now lists a radial gradient's stops from the first to the
last. Each stop is written with its own midpoint to the next stop, and
section 3.5.2 says the last stop's midpoint is ignored. Linear
gradients are unchanged.
2026-10-21 15:20:18 +07:00
Rahul Ranjan 9ce5ffd244 Offset Path's Preview checkbox keeps its last state across opens (#3195)
The Offset Path dialog remembers the values it was last confirmed
with (#1152), but the Preview checkbox was not among the kept
fields, so every open started unchecked and Preview had to be
ticked manually each time. Preview now rides along with offset,
joins and miterLimit in the fields kept on OK, as Simplify's
retained fields already keep theirs.

Unchanged: a very first open (nothing kept yet) still starts
unchecked, and Cancel keeps nothing.

Adds a regression test beside offset_path_remembers_its_last_values:
open, tick Preview, confirm, reopen - the checkbox is still on.

Verified by reading the flow on current main (menus.rs opens on
app.ui.offset_path when it is an object, d.bool("preview") defaults
to false when the field is absent, form::preview_result ticks the
checkbox into d.fields) and by tracing the existing #1152 test;
no Rust toolchain on this machine, so the test was not executed
here - saying so plainly for the reviewer.

Co-authored-by: rahul05ranjan <[email protected]>
2026-10-21 15:20:17 +07:00
HEOJUNFOandClaude Opus 5.5 5569ae97a6 Leave the pattern tile edge and swatch bounds out of exports (#3261)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 15:20:16 +07:00
Mex 97d133d9d4 M3.57: preserve fractional CSS gradient stop positions 2026-10-21 15:20:15 +07:00
dotCooCoo 8712be97b0 Libraries: a User Defined library file whose reading panics is skipped
The desktop app reads the User Defined swatch and graphic style
libraries while it creates its window: set_user_dir runs
Libraries::rescan, which read and parsed every file in the folder
outside the panic guard that commands have. A parser bug that panicked
on one file stopped the app before its window appeared, at every
launch while the file stayed in the folder.

rescan now reads and parses each file inside guard::catch_panic and
skips a file whose reading panics, as it skips one that can't be read.
The other libraries are listed.
2026-10-21 15:20:15 +07:00
Jürg Lehni 494343c274 Fix: the root-argument tests give the app 30 seconds to exit, not 5, which the binary's start alone took on a loaded machine (the workspace's other tests running meanwhile), so they failed there; 30 still catches a desktop that started instead of exiting 2026-10-21 15:20:14 +07:00
NetterSilverandClaude Haiku 5.5 acee6cccd8 Keep fallback library name for blank GPL Name line
Found while testing crates/color/src/palette_io.rs:373

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 15:20:13 +07:00
NetterSilverandClaude Haiku 5.5 89c1448777 Normalise negative zero in Measure tool angle
Found while testing crates/tools/src/xform/measure.rs:20 (shown at :66, reported at :80)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 15:20:12 +07:00
NetterSilverandClaude Haiku 5.5 781d68a971 Encode trailing space as _x20_ in XML ids to keep round trip
Found while testing crates/doc/src/xml_id.rs:80 (strip_unique), triggered by from_object_name at :32

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 15:20:11 +07:00
NetterSilverandClaude Haiku 5.5 7c8e772b46 Quote CSS font-family names that are not plain identifiers
Found while testing crates/svg/src/css.rs:112

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 15:20:11 +07:00
NetterSilverandClaude Haiku 5.5 2b0d71dbea Fix EPS writer emitting inf for huge finite numbers
Found while testing crates/eps/src/ps.rs:36

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 15:20:10 +07:00
bflatastic 15babbcacc Water Paper: no Adobe address in the shipped effect description, clippy (#2955) 2026-10-21 15:17:05 +07:00
bflatastic 55df023b65 Water Paper: rustfmt (#2955) 2026-10-21 15:17:04 +07:00
nrq6993-a11y 078e67a386 G2: Effect › Sketch › Water Paper raster filter (#2955) 2026-10-21 15:17:04 +07:00
Dylan M. Taylor bc1ae08252 Merge pull request #3224 from Matoo125/fix/emf-pattern-tile-budget
EMF export: pattern tiles stop at 250,000 in all
2026-10-20 12:17:30 -04:00
Dylan M. Taylor cdc4ecc67e Merge pull request #3223 from Matoo125/fix/nested-repeat-budget
Repeat: nested Repeats stop at 250,000 instances in all
2026-10-20 12:17:16 -04:00
Dylan M. Taylor 0c42504069 Merge pull request #3194 from Matoo125/fix/mesh-remove-lines-malformed
Gradient meshes: deleting mesh lines leaves a malformed mesh as it is
2026-10-20 12:17:01 -04:00
Dylan M. Taylor 31eafe3f8d Merge pull request #3193 from Matoo125/fix/mesh-point-count-overflow
Gradient meshes: a grid whose point count overflows is malformed
2026-10-20 12:16:47 -04:00
Brandon ThomasandClaude Opus 5.5 2c1391cf40 Skip CodeRabbit reviews on Dependabot PRs (#3247)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:38:32 -04:00
Dylan M. Taylor ae663f53aa Merge pull request #2923 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:31:29 -04:00
Martin Vlach dd7991cb20 EMF export: pattern tiles stop at 250,000 in all
One pattern fill tiles at most 250,000 times (PatternDef::offsets_covering),
but Scene::fill writes each tile's own pattern fills again, down to the
nesting limit of 8, with nothing bounding the total. A pattern whose tile
art is filled with another pattern (or with itself) multiplies the tile
count at every level: a small document makes EMF export (the engine's
"emf" format, also reachable over MCP and the control channel) run out
of memory or never finish, and the unsaved document is lost with it.

The scene now keeps one tile budget for the whole walk. A fill whose
tiles don't fit in what is left, and every pattern fill after it, is
written as one colour like WMF writes patterns, with a warning.

The test tiles squares filled with dots (over half a million fills before)
and a pattern filled with itself, and checks both stay under the budget.

Reported-by: BS.ehlas.cz
2026-10-20 14:26:00 +02:00
Martin Vlach 03b2960f4d Repeat: nested Repeats stop at 250,000 instances in all
One Repeat makes at most 250,000 instances (a 500 x 500 grid; radial
counts stop at 1,000), but its source may hold another Repeat and every
instance draws that one again: expand() clones the inner Repeat once per
instance, and the renderer, the exporters (expand_deep) and hit testing
evaluate each copy. Nothing bounds the product, so a document under 1 KB
with a 500 x 500 grid of a 500 x 500 grid asks for 62.5 billion
instances: opening it freezes the app or runs it out of memory, and the
unsaved work in other documents is lost with it.

A Repeat now counts the instances the Repeats nested in its source make
in all and keeps only as many of its own as fit in 250,000, the budget
pattern fills already keep (at least one, so the source still shows).
A Repeat with nothing nested keeps all its instances, as before.

The test nests a 500 x 500 grid in a 500 x 500 grid and in a mirror,
counts the instances from the transforms (62.5 billion before) and
checks they stay within the budget, while one grid and a small nest keep
all of theirs.

Reported-by: BS.ehlas.cz
2026-10-20 14:23:50 +02:00
Martin Vlach da8e6af7e8 Gradient meshes: deleting mesh lines leaves a malformed mesh as it is
GradientMesh::remove_point_lines (the Mesh tool's Alt-click,
object.mesh.deletePoint) only checked that the clicked point exists,
then indexed the points above and below or left and right of it by the
mesh's rows and columns. A gradient mesh read from a document is not
checked against its rows and columns, so one with fewer points than
they need (rows 2, columns 1 and 3 points instead of 6) indexes past
the end and panics: the engine guard rolls the command back with an
internal error, and the web app, which has no guard, aborts.

It now returns false for a mesh that isn't valid, as locate (and with
it adding mesh lines), the outline and the mesh lines already do, and
the command reports that nothing could be deleted.

The test Alt-clicks an interior row's point and an interior column's
point of two such meshes and checks nothing is deleted and the mesh is
unchanged.

Reported-by: BS.ehlas.cz
2026-10-20 09:20:24 +02:00
Martin Vlach bf6692ae82 Gradient meshes: a grid whose point count overflows is malformed
GradientMesh::is_valid and the two mesh envelope checks (envelope_grid
and the envelope's surface) compare the number of points with
(rows + 1) × (cols + 1), multiplying the document's u32 rows and
columns unchecked. With u32::MAX rows and columns the product is 2^64:
debug builds panic on the overflow and release builds wrap it to 0, so
a mesh or a mesh envelope without points passes as valid. Selecting
it, Outline view, hit testing and export then index points that aren't
there (the mesh lines, outline and edges), tessellating it asks for an
impossible capacity, and a mesh envelope with handles to fill in loops
2^64 times making them until memory runs out. On wasm32, where usize
has 32 bits, 65,535 rows and columns are enough, and a panic there
aborts the web app.

The three checks now share grid_len, which counts the points with
checked arithmetic and gives None when the count doesn't fit in a
usize: a grid that can't be counted matches no point list, so it is
malformed like any other wrong count.

The test checks that a u32::MAX × u32::MAX mesh without points isn't
valid and has no lines, outline or quads, that envelope_grid refuses
those counts, and that a mesh envelope with them leaves art in place.

Reported-by: BS.ehlas.cz
2026-10-20 09:18:33 +02:00
Brandon ThomasandClaude Opus 5.5 ad0566943a Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:18:48 -04:00
BFlat 9e7929ee89 Merge pull request #3173 from KaboomFox/p6-effect-paint
Plug-ins: live effects read their object's fills and strokes (G17)
2026-10-20 08:49:13 +07:00
Louise FoxandClaude Opus 5.5 2e4666a364 Plug-ins: live effects read their object's fills and strokes (G17)
A live-effect plug-in was given only its object's geometry. An effect
that draws as wide as the stroke it sits on, or tells its fills apart,
had to ask for them again as parameters, which then went stale when
the paint changed.

A live effect's input object now holds the object's fills and strokes
too, read only and in the form a filter gets them (one encoder,
objects::paint_lists, for both), and _context.item says where the
effect sits: "object", or {"fill": i} / {"stroke": i} for an effect in
a fill's or a stroke's own effects. effects::GeomContext carries the
object's appearance and the item, and the plug-in cache keys on the
paints, so a change of paint runs the plug-in again. Hosts from before
this give geometry only; nothing in the output changes.

Expand worked a fill's geometry effects out in the object's context, so
a plug-in effect there would be told "object" and a gradient fill was
expanded into a shape the canvas doesn't draw: fill_shape now gives them
the fill's context, as drawing does (expand.rs).

Siblings named with a declared prefix, the other half of such context,
are left out: the effected geometry is worked out from the object alone
wherever it is drawn, measured, hit-tested or expanded.

wat::when_given is a plug-in whose output depends on whether its input
or parameters hold a text. tests_plugins.rs uses it to check that an
effect sees the stroke's width and the fill, runs again for a thinner
stroke, and on a second stroke is told so; effect.rs checks the item
counting; a gradient fill under such an effect is drawn and expanded
into the same triangle. CRAFT_PLUGIN_PAINTS_WASM=<folder> saves a test
effect to try in the app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:39:41 +07:00
BFlat ee4564c586 Merge pull request #2954 from nrq6993-a11y/stream/vc-feat-g2-sketch-note-paper
G2: Effect › Sketch › Note Paper
2026-10-20 08:39:16 +07:00
bflatastic c9d769ad4e Note Paper: grey tones on white like the other Sketch filters, rustfmt (#2954) 2026-10-20 08:30:05 +07:00
bflatastic 62e8474caa i18n and docs: Note Paper's catalog rows, raster effects 46 of 57 (#2954) 2026-10-20 08:29:26 +07:00
nrq6993-a11y 301783c480 G2: Effect › Sketch › Note Paper raster filter (#2954) 2026-10-20 08:28:51 +07:00
BFlat 64699f5212 Merge pull request #3172 from KaboomFox/p2-rich-params
Plug-ins: labels, units, groups, help and conditions for parameters (G17)
2026-10-20 08:27:36 +07:00
BFlat bea46e63ff Merge pull request #3171 from KaboomFox/p1-exporter-plugins
Plug-ins: exporters write file formats of their own (G17)
2026-10-20 08:27:26 +07:00
Louise Fox c755909d67 Plug-ins: labels, units, groups, help and conditions for parameters (G17)
A plug-in's dialog labelled each field with its parameter's name spaced
out ("Line_spacing_mm:"), showed numbers without their unit and listed
at most 64 parameters in one column. A plug-in with many settings, such
as a generator's or an exporter's, could neither explain nor sort them,
and its dialog showed settings that don't apply to the choices made.

Each parameter may now say how dialogs show it, in keys that hosts
from before them ignore (plugins::dialog):

- label; unit, after the value ("0.25 mm"); help, on the label; and
  helpUrl, an https:// page opened from a button after the field.
- group: the parameters outside any group come first, then each group
  as a section that folds, in the order the groups first appear, the
  fields lined up down the dialog.
- visibleWhen: {"pattern": ["lines"]} shows a parameter only
  while each checkbox or choice it names, declared before it, has one
  of the values listed and is shown itself. Manifest::visible decides
  every parameter in one pass, in declaration order. A hidden parameter
  still goes to the plug-in with its value, so a run never depends on
  what the dialog showed.

Two types join number, int, bool and choice: string, a text field cut
to maxLength, and numberList, numbers typed separated by commas or
spaces, each clamped, minItems to maxItems of them. A plug-in declares
up to 256 parameters, at most 64 outside groups. The filter, live-effect
and exporter dialogs use every key. The Appearance panel's editor of a
plug-in effect uses the labels, units, help and conditions, and reads a
typed number list as the dialog does.

dialog.rs tests parsing, the round trip of a manifest written out,
sections, visibility (also of 256 overlapping conditions, which a
recursive check would take forever on) and the keys refused;
manifest.rs the new types and limits. tests_plugin_fields.rs draws a
dialog with kittest: labels and units, sections in order with their
fields lined up, visibleWhen hiding and showing rows, and the help
button opening its page.
appearance.rs checks the editor's labels, units, conditions and a
typed number list, and form.rs units after numbers (a unit starting
with a digit is spaced from it) and typed lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un

# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-20 08:17:53 +07:00
Louise FoxandClaude Opus 5.5 30f372a220 Contributors: credit KaboomFox by their GitHub profile name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:17:48 +07:00
Louise FoxandClaude Opus 5.5 24936cd006 Plug-ins: exporters write file formats of their own (G17)
A plug-in could filter objects or reshape them as a live effect, but
nothing it made could leave the app: pen plotters, cutting machines and
laser cutters read formats VectorCraft doesn't write. An exporter is a
third kind of plug-in ("kind": "exporter", with the file "extensions"
it writes), in the same sandbox and with the same ABI v1 exports.

- It adds a write-only format, plugin.<id>. document.formats lists it
  with its plug-in id, and document.export writes it by that id, or by
  a path ending in one of its extensions that no built-in format has;
  "params" carries its parameters, checked against its manifest as a
  filter's are.
- File › Export › Export As lists it after the built-in formats, with
  Use Artboards greyed out, since the plug-in writes the whole
  document. One with parameters asks for them in a dialog generated
  from its schema (pluginExport) once the file is picked.
- The plug-in is given the native file's JSON (format::json_part:
  images of 8 KiB or more without their data) and _context {mode:
  "export", extension, name}. It returns {"dataBase64", "warnings"?}
  or {"error"}. The host writes the file, and nothing when the plug-in
  declines or returns bad data.
- An export gets 10 G instructions plus 4,000 per byte and 300 s.
  Background Export's Cancel stops it between two slices of that
  budget (Error::Cancelled), and a cancelled live-effect run is not
  cached as a failure.
- plugin.run and Object › Plug-ins say where an exporter is instead of
  calling it a live effect.

engine/tests/plugin_export.rs reads back what an exporter is handed
with wat::echo_exporter, an exporter whose file is its input or its
parameters (its base64 encoder is written in WAT). The document it is
given is document.save's JSON, without the large image's data, and its
parameters arrive defaulted, clamped and with the file's name and
extension. It also covers listing and removal, built-in extensions
winning, selectedOnly, declines, bad base64, missing data, warnings,
and Cancel stopping a plug-in that never returns. It is a test binary
of its own: installed plug-ins are process-wide, and the format-list
unit tests check exact lists. dialogs/tests_plugin_export.rs drives
Export As headlessly; plugins/tests/abi.rs covers the budget, the
parameters and the echo exporter; format/tests/native_v4.rs covers
json_part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:17:48 +07:00
BFlat 1dda8d9e67 Merge pull request #2268 from danielmigueltejedor/fix/gnome-window-decorations
Fix GNOME/Wayland window controls
2026-10-20 07:40:12 +07:00
bflatastic 20557277c2 Packaging: package.sh stays executable (#2268) 2026-10-20 07:34:47 +07:00
Daniel 050f68a030 fix(linux): enable font-free Wayland window controls 2026-10-20 07:34:46 +07:00
BFlat 39fdaf7db8 Merge pull request #2565 from dotCooCoo/feat/artboard-size-applies-to-selected
Artboards: Width, Height, Preset and Orientation apply to every selected artboard
2026-10-20 07:32:56 +07:00
BFlat 2544d7f090 Merge pull request #3087 from Zaxaerith/docs/2980-typography-lettering
docs: add typography and custom lettering guide (#2980)
2026-10-20 07:32:40 +07:00
BFlat fd69aa0623 Merge pull request #3029 from Zaxaerith/docs/tiled-printing-guide-2857
docs: make tiled poster printing discoverable (#2857)
2026-10-20 07:32:24 +07:00
BFlat 5dd86dc19c Merge pull request #2408 from dotCooCoo/feat/paste-ai-editing-data
Clipboard: a copied .ai or .ait file pastes the objects of its editing data
2026-10-20 07:32:09 +07:00
dotCooCoo 7617fcd536 Artboards: Width, Height, Preset and Orientation apply to every selected artboard (#2565) 2026-10-20 07:23:49 +07:00
Zaxaerith c3083e245d docs: add typography and custom lettering guide (#2980) 2026-10-20 07:23:47 +07:00
Zaxaerith 0688ad04d7 docs: document tiled poster printing (#2857) 2026-10-20 07:23:47 +07:00
dotCooCoo 89986d01bf Clipboard: a copied .ai or .ait file pastes the objects of its editing data (#2408) 2026-10-20 07:23:40 +07:00
BFlat 4cd77a7a18 Merge pull request #3110 from KnottyDyes/squad/cli-install-docs
Docs: install the CLI and register it with MCP clients
2026-10-20 07:19:18 +07:00
BFlat 19efa9ea2b Merge pull request #2575 from 00xJS/fix/2213-large-image-clipboard-text
Fix #2213: a copy with a large image no longer puts its SVG on the clipboard as text
2026-10-20 07:18:59 +07:00
KnottyDyes 371d0de798 Docs: install the CLI and register it with MCP clients 2026-10-20 07:10:21 +07:00
00xJ0 548ace24d2 Clipboard: a copy with a large image no longer puts its SVG on the clipboard as text (fixes #2213)
With Include SVG Code on, Copy offered the SVG markup as plain text with every placed image
embedded as base64: a 13 MB photo made 17 MB of text. A paste reads the text first (eframe on
every paste key; on Linux and macOS it is the one format written, and holds_ours reads it again),
so each paste moved it through the system clipboard on the UI thread, and on GNOME the app
stopped responding.

The SVG markup now goes out as text only while the images it embeds total at most 1 MiB; above
that it is offered as image/svg+xml and PNG. Type keeps its text, and vector art keeps its SVG
text at any size (only embedded image bytes count).

Windows 11, Ryzen 5 2600X, 13 MB PNG, real Ctrl+C: the longest UI stall fell from 823-1021 ms
to 133 ms. Not run on Linux.
2026-10-20 07:10:21 +07:00
BFlat a13968e821 Merge pull request #3050 from nrq6993-a11y/stream/vc-feat1-g1-group-selection-shift-deselect
G1: Group Selection Shift-click deselects compound path members
2026-10-20 07:10:07 +07:00
nrq6993-a11yandCursor e2e2ac12a9 Group Selection: fold MoveObject pointer-up into shared arm
Read deselect before idle reset and handle shift-deselect via select.toggle in the shared Up match.

Co-authored-by: Cursor <[email protected]>
2026-10-20 07:00:49 +07:00
nrq6993-a11yandCursor 61f35cd037 G1: Group Selection Shift-click deselects compound path members
Match the Selection tool: Shift on an already selected hit removes it on
release without a drag, including one path of a compound path.

Co-authored-by: Cursor <[email protected]>
2026-10-20 07:00:49 +07:00
BFlat 891793b3a4 Merge pull request #3167 from sungjea713/assets-tab-scrolls-to-checked
Export for Screens: the Assets tab scrolls to the assets it opens on
2026-10-20 07:00:28 +07:00
sungjea kim 6b24e3cd6a Export for Screens: the Assets tab scrolls to the assets it opens on
Export Selection… and the Asset Export panel open Export for Screens on the
Assets tab with the new assets checked, but new assets land at the end of
the list, so in a document with many assets the checked one sat below the
list's visible part and had to be scrolled to by hand. The list now scrolls
to the first checked asset when the dialog opens on them. It keeps asking
until the row is in view (the dialog's first frame only measures it), then
stops, so the list scrolls freely afterwards.
2026-10-20 06:54:13 +07:00
BFlat 02e12c6935 Merge pull request #2533 from vangel76/feat/2527-sketch-filters
Effects: Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527)
2026-10-20 06:53:21 +07:00
Martin Vlach f664564e97 Effects: Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527)
Squashed from #2533 onto #2525 (Smudge Stick, Sponge, Watercolor): both sets in PIXEL_EFFECTS (45), the catalogs and the docs, whose counts now read 45/57.
2026-10-20 06:43:50 +07:00
BFlat d14b67e015 Merge pull request #2525 from vangel76/feat/2435-artistic-filters
Effects: Artistic: Smudge Stick, Sponge and Watercolor (#2435)
2026-10-20 06:41:08 +07:00
Martin Vlach 5251246778 Effects: Artistic: Smudge Stick, Sponge and Watercolor (#2435)
Squashed from #2525 (its merges of main dropped).
2026-10-20 06:32:32 +07:00
BFlat 1097ab190d Merge pull request #3166 from sungjea713/export-selection-single-asset
Export Selection: the selection becomes one asset, not one per object
2026-10-20 06:32:06 +07:00
sungjea kim 17ba27a0f6 Export Selection: the selection becomes one asset, not one per object
File › Export Selection… collected the selected objects one asset per
object, so a few ungrouped shapes picked to export together came out as
separate images unless they were grouped first. It now collects them as one
asset of them all (`assets.add {multiple: false}`), the selection exported
as one image; `file.exportSelection {multiple: true}` still makes one per
object, and Object › Collect for Export › As Multiple Assets and the Asset
Export panel's Multiple Assets item are unchanged.
2026-10-20 06:26:11 +07:00
BFlat 4322c825a5 Merge pull request #3057 from nrq6993-a11y/stream/vc-feat-g1-grid-ref-point
G1: Rectangular and Polar Grid dialogs reference point locator (#1377)
2026-10-20 06:14:43 +07:00
bflatastic ddab0152af fmt (#3057) 2026-10-20 06:08:16 +07:00
nrq6993-a11yandCursor f12ad3aa0a G1: Pin grid dialog bounds to canvas click via reference locator (#1377)
Store the click as __anchorX/__anchorY and recompute x/y when the locator opens or changes so the chosen point stays on the click like Illustrator.

Co-authored-by: Cursor <[email protected]>
2026-10-20 06:07:29 +07:00
nrq6993-a11yandCursor 699e6e9af4 G1: Grid tool dialogs add reference point locator (#1377)
Rectangular and Polar Grid click dialogs show the nine-point locator and resize width/height about it using the same artboard_rect logic as Artboard Options.

Co-authored-by: Cursor <[email protected]>
2026-10-20 06:07:29 +07:00
BFlat 059b6d083c Merge pull request #3165 from danngreen/fix/2143-guide-key-object
Fix #2143: ruler guides and key objects align to each other
2026-10-20 06:07:00 +07:00
Dan Green 7852f363a1 Fix #2143: selected ruler guides align to a key object
A lone object selected with ruler guides can be the key (the Selection
tool's click on it), and Align to the key moves the other selected
guides to its edge or centre: vertical guides to left, centre or right,
horizontal ones to top, centre or bottom. Parallel guides line up on a
key guide. Aligning to the selection or the artboard leaves guides alone.
2026-10-20 05:56:59 +07:00
Dan Green 263c36567f Fix #2143: a ruler guide can be the key object Align lines objects up on
A click with the Selection tool on a guide selected with objects makes
it the key (select.key {guide}), drawn thicker; a click on it again
cancels it. Align to Key Object is on with one object and a selected
guide and makes the guide the key. object.align lines the objects (or
selected anchors) up on the guide's line and leaves the guide where it
is: a vertical guide sets x only, a horizontal one y. The selection
keeps key_guide beside key, never both; it goes with the guide or the
objects, and follows the guide when earlier guides are deleted.
Distribute Spacing doesn't space from a key guide.
2026-10-20 05:56:58 +07:00
BFlat 398d2e19de Merge pull request #3163 from Matoo125/fix/polygon-sides-from-file
Live shapes: a polygon read from a file has 3 to 1000 sides
2026-10-20 05:45:38 +07:00
BFlat 0567752448 Merge pull request #3162 from danngreen/feat/libraries-panel-scroll-reorder
Libraries panel: scrolling, and drag to reorder items and groups
2026-10-20 05:36:04 +07:00
Martin Vlach 38e73060a7 Live shapes: a polygon read from a file has 3 to 1000 sides
Every command keeps a live polygon's side count in 3..=1000
(shape.polygon, object.setLiveShape, the Polygon tool), but reading a
document took `sides` as any u32. Selecting such a polygon builds its
Live Corners: LiveShape::polygon_outline makes one point per side and
set_corners resizes the corner radii and kinds to the side count. A
document a few bytes long ("sides": 4000000000) makes the app allocate
tens of gigabytes and abort or stall, and the unsaved work in other
documents is lost with it. Transforming such a polygon with cut
corners regenerates the same outline.

`sides` is now clamped to 3..=1000 when it is deserialized, so a
polygon from any document that is read (opened, placed, packaged or
taken from a library) keeps the limit the commands keep, and the code
that uses the count needs no check of its own.

The test reads polygons with 0, 5, 1000 and 4,000,000,000 sides,
checks they come out with 3, 5, 1000 and 1000, and that the clamped
one's outline has 1000 anchors.

Reported-by: BS.ehlas.cz
2026-10-20 05:28:32 +07:00
Dan Green 6bed87b5fb Libraries panel: the list scrolls; items and groups reorder by dragging, items drag into groups
The list fills the panel down to its bottom bar and scrolls, also while a drag nears its top or
bottom edge (in the dock and in a flyout, as the Layers panel does).

Dragging in the list:
- an item (colour, text style or graphic) dropped on another of its kind lands just before or
  after it, by the pointer's half, an accent line showing where, and joins that item's group;
- dropped elsewhere on a group (its header too, even collapsed) it goes into that group, and on
  the ungrouped items out of its group, the section outlined; with every item grouped, a
  "Drop here to take it out of its group" strip shows while one is dragged;
- a group dragged by its header goes before or after another group.
Colours and graphics still drag onto art and the canvas as before.

Engine: library.moveItem takes `before` / `after` (an item of the same kind) to place the item
in its kind's order, which the panel shows; the new library.moveGroup {group, to} moves a group.
Text styles drag as PanelDrag::LibraryItem, groups as PanelDrag::LibraryGroup, and a library
colour's Paint drag names its library item, so the panel tells it from a swatch dragged in.

Tests: reordering and moving in the engine, the commands in the library fuzz test, and panel
tests that drop styles and group headers and check the list scrolls and scrolls while dragging.
Strings translated in the 23 catalogs; docs/mcp.md and the parity row updated.
2026-10-20 05:28:17 +07:00
BFlat 6f3c974984 Merge pull request #3056 from nrq6993-a11y/stream/vc-feat1-g5-art-brush-overlap
G5: Art brush Overlap option in Brush Options
2026-10-20 05:17:39 +07:00
bflatastic cade497b79 fmt (#3056) 2026-10-20 05:06:37 +07:00
nrq6993-a11yandCursor feda1c062f G5: Add Overlap to complete i18n catalogs for art brush options.
Co-authored-by: Cursor <[email protected]>
2026-10-20 05:05:51 +07:00
nrq6993-a11yandCursor b929561fc3 G5: Art brush Overlap option bends art per corner run
Co-authored-by: Cursor <[email protected]>
2026-10-20 05:05:50 +07:00
BFlat 4be63905b1 Merge pull request #2990 from nrq6993-a11y/stream/vc-feat-g2-sketch-plaster
G2: Effect › Sketch › Plaster raster filter
2026-10-20 05:04:06 +07:00
bflatastic 8a7a1cf199 i18n: Plaster in every catalog that has the Sketch filters (#2990) 2026-10-20 04:52:37 +07:00
MarekSpodBiedry b560b97616 G2: Effect › Sketch › Plaster raster filter
Squashed from #2990 onto Chrome.
2026-10-20 04:52:26 +07:00
BFlat d21f3e0667 Merge pull request #3159 from odgrim/fix/pdf-import-invisible-text
Keep a PDF's invisible text (a scanned page's OCR layer) as type, and save it invisible (#2675)
2026-10-20 04:50:38 +07:00
Brian TorresandClaude Opus 5.5 ac7f0eebf8 PDF import and export: keep invisible text (rendering mode 3) as type (#2675)
Text drawn in rendering mode 3 (3 Tr), which is how a scanned page carries
its recognized OCR text, was left out on open (since #2988, with a warning),
so a scanned PDF opened and saved again lost its searchable text.

Import: an invisible glyph now comes in as type with neither fill nor
stroke. It skips the glyph-outline checks that guard how visible type looks,
and it joins only other invisible text on a line (Look::takes no longer
lets it into a stroke-only run). INVISIBLE_TEXT_NOTE now only reports text
that can't be type (text opened as outlines, Type 3 fonts), and
tests_reader_compat::invisible_text_is_reported_when_left_out expects the
invisible line as unpainted type when text opens as type.

Export and print: characters with no fill and no stroke are written as real
text inside a form marked Mark::Invisible; forms::finish sets that form's
text rendering mode to 3, so it draws nothing but stays selectable and
searchable (PDF/X-1a and PDF/A-2b too). The writer has no invisible mode of
its own, so this reuses the marked-form mechanism used for layers and
overprint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 04:43:11 +07:00
BFlat 498d17f72e Merge pull request #2985 from nrq6993-a11y/stream/vc-feat-g2-sketch-chrome
G2: Effect › Sketch › Chrome raster filter
2026-10-20 04:32:36 +07:00
bflatastic 326b1c3139 i18n: Chrome in every catalog that has the Sketch filters (#2985) 2026-10-20 04:21:13 +07:00
MarekSpodBiedry 2ff683d5fe G2: Effect › Sketch › Chrome raster filter
Squashed from #2985.
2026-10-20 04:20:50 +07:00
BFlat fd780c51b0 Merge pull request #3156 from MarekSpodBiedry/issue-3126
Draw the isolation bar over the canvas instead of squeezing it (#3126)
2026-10-20 03:51:47 +07:00
BFlat 1b6552a4fa Merge pull request #3157 from MarekSpodBiedry/issue-3135
Add a generic family to the font-family of SVG export and CSS (#3135)
2026-10-20 03:51:31 +07:00
BFlat 8918747a24 Merge pull request #3158 from MarekSpodBiedry/issue-3141
Ask for the .ai options on Save a Copy like Save As (#3141)
2026-10-20 03:51:22 +07:00
MarekSpodBiedry ef99948cd5 Draw the isolation bar over the canvas instead of squeezing it (#3126) 2026-10-20 03:42:11 +07:00
bflatastic 04139ad0b6 fmt (#3157) 2026-10-20 03:42:11 +07:00
MarekSpodBiedry 0f855be77f Add a generic family to the font-family of SVG export and CSS (#3135) 2026-10-20 03:41:17 +07:00
MarekSpodBiedry 7c47692b32 Ask for the .ai options on Save a Copy like Save As (#3141) 2026-10-20 03:41:16 +07:00
BFlat 8a6e4e77f3 Merge pull request #2997 from nrq6993-a11y/stream/vc-feat-g1-guide-alt-orientation-move
G1: Alt-drag an existing ruler guide to swap horizontal and vertical
2026-10-20 03:29:19 +07:00
BFlat 686da2dce7 Merge pull request #3155 from storytold/docs/effects-parity-2026-10-19
Docs: effects parity counts the Sketch and Artistic filters of 2026-10-19 (36/57)
2026-10-20 03:25:23 +07:00
BFlat 68b1f7a07c Merge pull request #3152 from Silvertree2010/fix/h652-svg-export-writes-invalid-xml-wh
Filter control characters in SVG export title and description
2026-10-20 03:25:07 +07:00
nrq6993-a11yandCursor 2ceb183bfe G1: restore Alt-drag copy for existing ruler guides (#2997).
Drop the undocumented orientation-swap on moved guides; Alt again previews guide.move with copy, matching new-guide Alt orientation only.

Co-authored-by: Cursor <[email protected]>
2026-10-19 23:17:28 +03:00
bflatastic fe6ef8ade7 Docs: effects parity counts the four Sketch and Artistic filters of 2026-10-19 (36/57) 2026-10-20 03:16:37 +07:00
NetterSilverandClaude Haiku 5.5 05017111ba Filter control characters in SVG export title and description
Found while testing crates/svg/src/export.rs:138 and :143

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 03:15:47 +07:00
BFlat 5397ad22e7 Merge pull request #3153 from storytold/fix/sketch-filter-rows
Effects: catalog rows for Chalk & Charcoal, Graphic Pen, Reticulation and Plastic Wrap (#2972, #2967, #2976, #3034)
2026-10-20 03:14:38 +07:00
bflatastic a5ec99d515 Effects: Graphic Pen's params doc names no vendor, as cargo xtask brands asks (#2967) 2026-10-20 03:14:27 +07:00
bflatastic 92c1657334 i18n: Chalk & Charcoal, Graphic Pen, Reticulation and Plastic Wrap in every catalog that has the Sketch filters (#2972, #2967, #2976, #3034)
Also cargo fmt over the four filters.
2026-10-20 03:14:27 +07:00
BFlat 8f481aa2f4 Merge pull request #3034 from nrq6993-a11y/stream/vc-feat3-g2-artistic-plastic-wrap
G2: Effect › Artistic › Plastic Wrap raster filter
2026-10-20 03:14:22 +07:00
BFlat 7616bb9221 Merge pull request #2976 from nrq6993-a11y/stream/vc-feat-g2-sketch-reticulation
G2: Effect › Sketch › Reticulation raster filter
2026-10-20 03:14:06 +07:00
BFlat 1f579352ed Merge pull request #2967 from nrq6993-a11y/stream/vc-feat-g2-sketch-graphic-pen
G2: Effect › Sketch › Graphic Pen raster filter
2026-10-20 03:13:50 +07:00
BFlat 851eb7f076 Merge pull request #2972 from nrq6993-a11y/stream/vc-feat-g2-sketch-chalk-charcoal
G2: Effect › Sketch › Chalk & Charcoal raster filter
2026-10-20 03:13:32 +07:00
nrq6993-a11y a2c764d312 G2: Effect › Artistic › Plastic Wrap raster filter
Squashed from #3034 onto the Sketch filters; the Artistic label test lists it.
2026-10-20 02:55:29 +07:00
nrq6993-a11y 917aa29dd5 G2: Effect › Sketch › Reticulation raster filter
Squashed from #2976 onto Chalk & Charcoal and Graphic Pen.
2026-10-20 02:54:06 +07:00
nrq6993-a11y 11adb72d44 G2: Effect › Sketch › Graphic Pen raster filter
Squashed from #2967 onto #2972 (Chalk & Charcoal): both filters in the effect catalog, PIXEL_EFFECTS and the Sketch label and filter tests.
2026-10-20 02:52:33 +07:00
nrq6993-a11yandCursor 52380d683d G2: Chalk & Charcoal: fix clippy on luma type
Co-authored-by: Cursor <[email protected]>
2026-10-20 02:50:13 +07:00
nrq6993-a11yandCursor 1f2007eb46 G2: Effect › Sketch › Chalk & Charcoal raster filter
Adds sketch.chalkCharcoal to the pixel-effect pipeline so Effect › Sketch exposes chalk highlights and charcoal strokes with public-doc-style parameters and tests.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:50:13 +07:00
BFlat 19e3144d27 Merge pull request #3144 from nrq6993-a11y/stream/vc-feat1-g1-envelope-distort-measure
G1: Mesh editing shows move distance in measurement labels while dragging points
2026-10-20 02:48:33 +07:00
nrq6993-a11yandCursor cfea906681 G1: Mesh point drags show dX/dY in measurement labels
Honor Measurement Labels while moving gradient-mesh and mesh-envelope
points with the Mesh tool or Direct Selection.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:48:08 +07:00
BFlat fbc654734f Merge pull request #3151 from storytold/docs/roadmap-2026-10-19
Docs: ROADMAP row for 2026-10-19, and rows doc-conflict merges duplicated
2026-10-20 02:41:11 +07:00
bflatastic ac83e91c5f Docs: the ROADMAP's 2026-10-19 row, and the rows merges of conflicting PRs left twice
ROADMAP: a progress row and status line for the community PRs of 2026-10-19; the reproducible-bytes and inch-ruler rows appeared twice. gaps.md: G6 held four interleaved versions of its Missing line; it now says what is left (a scripting surface). target-app-parity.md: three copies of the Type evidence bullet, merged into one with every sentence. type-parity.md and ui-parity.md: a row and a shorter copy of it.
2026-10-20 02:41:01 +07:00
BFlat 10e59b0d13 Merge pull request #3149 from texjer/fix/home-recent-hover
Home: Recent Files rows highlight and click as one; Home screen scrolls
2026-10-20 02:38:04 +07:00
BFlat dc79640956 Merge pull request #3146 from nrq6993-a11y/stream/vc-feat1-g1-envelope-distort-move-measure
G1: Envelope Distort shows move distance in measurement labels while dragging corners
2026-10-20 02:37:46 +07:00
BFlat e2b2c410d9 Merge pull request #3148 from Silvertree2010/fix/3132-can-t-delete-the-outer-part-of-a
Keep an island inside a hole a separate Shape Builder region
2026-10-20 02:37:34 +07:00
texjer 30646c83bd Home: Recent Files rows light up and click as one; Home scrolls
Each Recent Files row (history icon, name, folder) is now a single
button that paints the hover token behind it, shows a pointing hand and
keeps the full path as its tooltip; a click anywhere on it opens the
file. Before, only the frameless file name reacted, with no hover
feedback.

The Home screen's column now sits in a vertical scroll area, so a small
window or a long Recent Files list no longer hides the presets and
Community links below the fold. Widgets scrolled out of view are
clipped and take no pointer.
2026-10-20 02:37:04 +07:00
nrq6993-a11yandCursor 361ebd29bf G1: Free Distort corner drags show dX/dY measurement labels
Honor Measurement Labels during envelope-style corner drags on the Free Transform tool, matching the Move handle readout.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:37:03 +07:00
NetterSilverandClaude Haiku 5.5 39daf465f5 Keep an island inside a hole a separate Shape Builder region
Fixes #3132

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 02:37:03 +07:00
BFlat d8b0dcfc04 Merge pull request #3100 from nrq6993-a11y/stream/vc-feat1-g1-warp-remember-defaults
G1: Effect Warp dialog remembers last confirmed style and sliders
2026-10-20 02:35:48 +07:00
BFlat d5cd54c9b8 Merge pull request #3127 from nrq6993-a11y/stream/vc-feat1-g4-properties-variable-font-axes
G4: Properties panel Variable Font axes for selected type
2026-10-20 02:35:32 +07:00
BFlat 443ffd6c6f Merge pull request #3124 from nrq6993-a11y/stream/vc-feat1-g4-snap-to-glyph
G4: View › Snap to Glyph snaps new type to live-text baselines and glyph bounds
2026-10-20 02:35:22 +07:00
BFlat 73a3347bbb Merge pull request #3121 from nrq6993-a11y/stream/vc-feat1-g4-touch-type-nudge
G4: Touch Type arrow keys nudge the selected character
2026-10-20 02:35:05 +07:00
BFlat 5c32d527f1 Merge pull request #3128 from nrq6993-a11y/stream/vc-feat1-g1-measure-tool-preference-gate
G1: Measure tool honors Measurement Labels preference
2026-10-20 02:34:49 +07:00
BFlat 1abe1961ca Merge pull request #3122 from nrq6993-a11y/stream/vc-feat1-g1-tool-measure-pref-gaps
G1: Pie, Live Corners, Width and Print Tiling honor measurement_labels
2026-10-20 02:34:34 +07:00
BFlat f922a635cb Merge pull request #3114 from nrq6993-a11y/stream/vc-feat1-g1-crop-image-measure
G1: Crop Image tool honors measurement labels while resizing the crop box
2026-10-20 02:34:19 +07:00
BFlat 91fde1d7fa Merge pull request #3099 from nrq6993-a11y/stream/vc-feat-g1-graph-plot-measure
G1: Graph tools show width and height in measurement labels while dragging
2026-10-20 02:34:03 +07:00
BFlat 6a8d52804b Merge pull request #3069 from nrq6993-a11y/stream/vc-feat1-g1-bracket-drag-hide-bbox
G1: hide bounding box while dragging type-on-path brackets
2026-10-20 02:33:48 +07:00
BFlat 8521e0a505 Merge pull request #3065 from nrq6993-a11y/stream/vc-feat-g11-bezier-drag-preview-freeze
G11: Pen bezier handle drag preview updates while the button is held (#834)
2026-10-20 02:33:37 +07:00
BFlat e96771d5ea Merge pull request #3064 from nrq6993-a11y/stream/vc-feat3-g9-ai-feather-art-style
G9: .ai import reads Adobe Feather art styles as live effects
2026-10-20 02:33:20 +07:00
nrq6993-a11yandCursor 223bfd29c2 ui-egui: satisfy clippy on warp id checks in effect dialog
Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:34 +07:00
nrq6993-a11yandCursor 12f7d41c38 G4: Properties panel shows Variable Font axes for selected type
Reuse the Character panel's Variable Font popover in Properties so axis
sliders are available without opening Character.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:34 +07:00
bflatastic 85163a7c82 Tests: the Snap to Glyph test says what it checks (#3124) 2026-10-20 02:23:34 +07:00
nrq6993-a11yandCursor 1c1fd57b33 G1: Warp Options remembers last confirmed slider values
New warp effect dialogs seed bend, distortion and orientation from the
previous OK instead of catalog defaults; editing an applied warp is unchanged.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:34 +07:00
bflatastic 88a444ef7b fmt (#3124) 2026-10-20 02:23:34 +07:00
nrq6993-a11yandCursor 0c62d9c02c G4: View › Snap to Glyph snaps new type to live-text guides
Wire the View menu toggle and add glyph baselines and bounds to drawing snap targets so point type placement lines up with existing text when Smart Guides are on.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:34 +07:00
bflatastic cb229ac08c fmt (#3121) 2026-10-20 02:23:33 +07:00
bflatastic b8f9c403a7 Measure: with Measurement Labels off the measured line still draws, only its readout goes (#3128) 2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor fd53416de3 G1: honor Show Measurement Labels on pie, corners, width, and tiling readouts
Tools that showed distance or angle overlays during drags now respect the
Units preference, consistent with shape and selection tools.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
bflatastic d507a73d09 fmt (#3114) 2026-10-20 02:23:33 +07:00
bflatastic 9435c5079d fmt (#3099) 2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 99aa4e6a7c G1: hide bounding box while dragging type-on-path brackets.
Match corner-radius and pie-widget drags: the Selection tool reports a transforming drag so the canvas drops the sharp selection box during bracket moves.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
bflatastic 0ee6b1ac68 fmt (#3065) 2026-10-20 02:23:33 +07:00
bflatastic b21b3edb6c fmt (#3064) 2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 88bd9bcd9e G4: Touch Type drag test treats omitted zero dx as no kerning
Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 6ef9f2dd93 G1: Measure tool honors Measurement Labels preference
Hide the preview line and readout when Smart Guides › Measurement Labels is off; options still report the last drag.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 6d713de980 G1: Crop Image honors Smart Guides measurement labels.
Gate the crop size readout on the preference and show it at the pointer while dragging the box.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 1eac4f5b79 G1: Graph tools show W/H measurement labels while dragging plot area
Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor a2aac6bead G11: live Pen bezier preview while dragging on Wayland (#834)
Tool canvas drags no longer require a non-zero pointer delta, so smooth-point previews update as the pointer moves when the compositor coalesces motion events.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 222ec72e28 G4: Touch Type arrow keys nudge the selected character
Keyboard increment (Shift ×10) moves the active glyph through text.touchType, with vertical type remapping like the Type tool.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:23:33 +07:00
nrq6993-a11yandCursor 8f7486b90f G9: .ai art styles read Adobe Feather as live effects
Plain art-style import maps Adobe Feather dictionaries to stylize.feather so styled objects stay editable on open.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:21:41 +07:00
BFlat 5c5684f4c5 Merge pull request #2500 from dotCooCoo/feat/outline-object-image-paint
Appearance: image fills and strokes draw the shape Convert to Shape or Outline Object gives them
2026-10-20 02:20:55 +07:00
BFlat 19adc36964 Merge pull request #2555 from dotCooCoo/feat/simplify-slider
Simplify: Object › Path › Simplify opens a slider with Auto-Simplify and More Options
2026-10-20 02:20:39 +07:00
BFlat f412d30adf Merge pull request #2994 from Higgs32584/warn-svg-artboard-loss
Save: warn when SVG keeps only one of several artboards
2026-10-20 02:20:23 +07:00
BFlat 7bf326eaf1 Merge pull request #3140 from Hamza0101/cmyk-jpeg-not-jfif
A CMYK JPEG is not labelled JFIF
2026-10-20 02:20:06 +07:00
BFlat bf021ae08b Merge pull request #3138 from Silvertree2010/fix/h650-intersects-rect-ignores-a-path-c
Fix intersects_rect ignoring paths crossing zero-width/height rects
2026-10-20 02:19:50 +07:00
BFlat 301727c7d5 Merge pull request #3142 from dotCooCoo/fix/graph-data-view-test-timing
Tests: Graph Data's full-size sheet test counts the cells it draws instead of timing them
2026-10-20 02:19:31 +07:00
bflatastic a52198cefa Appearance: image fills and strokes draw the shape Convert to Shape or Outline Object gives them
Squashed from #2500 (its merges of main dropped).
2026-10-20 02:06:54 +07:00
dotCooCoo 926ca62952 Simplify: Object › Path › Simplify opens a slider with Auto-Simplify and More Options
Squashed from #2555 (its merges of main dropped).
2026-10-20 02:06:54 +07:00
bflatastic d3ee9646de i18n: the SVG one-artboard warning in every complete catalog and Bulgarian (#2994) 2026-10-20 02:06:54 +07:00
Higgs32584 a446d76637 fix changes 2026-10-20 02:05:02 +07:00
Michael Higgins 81cf399bfa Refactor SVG options and error handling 2026-10-20 02:05:02 +07:00
Michael Higgins d8c20c8eb4 Update error message for artboard preservation 2026-10-20 02:05:02 +07:00
Hamza f106f32af4 A CMYK JPEG is not labelled JFIF
A CMYK JPEG came out with JFIF's APP0 segment at its head and four components
in the frame:

  APP0/JFIF  APP14/Adobe  APP2[ICC_PROFILE 1/4 … 4/4]  SOF0: 4 components

JFIF describes one- and three-component images, greyscale and YCbCr, so an APP0
segment in front of a four-component frame says two different things about the
file's colours: a reader that trusts JFIF decodes nonsense, and one that checks
refuses the file. Adobe's own CMYK JPEGs carry the APP14 marker the encoder
already writes, and no JFIF. `jpeg-encoder` writes JFIF unconditionally, so the
segment is replaced here.

The resolution JFIF carried moves to an EXIF APP1 segment -- `XResolution`,
`YResolution` and `ResolutionUnit` -- which is where `cmd::fileio::ppi` already
looks when a file has no JFIF, so nothing is lost; with no resolution asked for
the segment is simply dropped. RGB and greyscale files keep JFIF, which is what
it is for.

Found while looking at #3084, whose default RGB exports are valid here; this is
the file that is not.
2026-10-20 02:05:01 +07:00
Michael Higginsandcoderabbitai[bot] acd8ca097d Update crates/engine/src/cmd/fileio/svg.rs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-20 02:05:01 +07:00
Higgs32584andClaude Opus 5.5 f9e559156f Save: decide the lost-artboards warning in the SVG handler
Move the Preserve Editing check out of save.rs into svg.rs, where it
reads the options through `plan` as the encoder does; SaveJob::encode
still puts the warning first. Review follow-up on #2994.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 02:05:01 +07:00
Higgs32584andClaude Opus 5.5 c2a605ce8e Save: warn when SVG keeps only one of several artboards
A document with more than one artboard saved as SVG/SVGZ without
Preserve Editing Capabilities is written as one artboard and reopens
with one, so the others were lost without a word. The save now says so
(first, since the status bar shows only the first warning) and names the
two ways to keep them all.

Refs storytold/vectorcraft#2091

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 02:05:01 +07:00
NetterSilverandClaude Haiku 5.5 ec4975f237 Fix intersects_rect ignoring paths crossing zero-width/height rects
Found while testing crates/geom/src/hit.rs:116

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 02:05:00 +07:00
dotCooCoo cb631ae635 Tests: Graph Data's full-size sheet test counts the cells it draws instead of timing them
a_full_size_sheet_draws_only_what_is_in_view timed three frames of a
10,001 × 257 sheet and failed when they took 20 s or more, as they did
on a FreeBSD CI runner. The test now counts the buttons in the
screen-reader tree of the window. A graph of 40 categories and 40
series, whose 42 × 42 sheet is larger than the view both ways, draws
fewer buttons than its 41 × 41 filled cells, and a full-size sheet
draws the same number.
2026-10-20 02:04:59 +07:00
BFlat 5087186eac Merge pull request #3015 from nrq6993-a11y/stream/vc-feat3-g9-ai-ruler-units
G9: .ai/.eps import restores ruler units from %AI5_RulerUnits
2026-10-20 02:02:30 +07:00
BFlat 84ab208c43 Merge pull request #2982 from nrq6993-a11y/stream/vc-feat1-g1-join-smooth-corner
path.join: corner or smooth at the merged anchor
2026-10-20 02:02:12 +07:00
BFlat ae617db6d3 Merge pull request #2983 from nrq6993-a11y/stream/vc-feat1-g1-draw-measure-labels
Drawing tools: distance and angle measurement labels while placing points
2026-10-20 02:01:54 +07:00
BFlat 408081000d Merge pull request #2966 from nrq6993-a11y/stream/vc-feat3-g1-spacing-guides-resize
G1: Spacing Guides snap bounding-box resizes to equal gaps
2026-10-20 02:01:35 +07:00
BFlat 34a9a48340 Merge pull request #2999 from nrq6993-a11y/stream/vc-feat-2991-isolation-shortcut
Allow assigning a keyboard shortcut to Enter Isolation Mode
2026-10-20 02:01:17 +07:00
BFlat 6de7c0a441 Merge pull request #2989 from nrq6993-a11y/stream/vc-feat3-2206-font-script-han
Font menu: label the Japanese script filter as 汉字 in Chinese UI (#2206)
2026-10-20 02:00:59 +07:00
BFlat b48290a4f9 Merge pull request #2981 from nrq6993-a11y/stream/vc-issue-2681-appearance-icon
Fix #2681: distinguish Appearance from Pathfinder panel icons
2026-10-20 02:00:42 +07:00
BFlat 7833756f6e Merge pull request #3003 from nrq6993-a11y/stream/vc-issue-2996-acb-filename
Fix #2996: color books copy to user folder under book title
2026-10-20 02:00:24 +07:00
BFlat ce998b89bc Merge pull request #3007 from nrq6993-a11y/stream/vc-feat3-g9-ai-nonprinting-guides
G9: Regression tests for Illustrator path guides from editing data (#779)
2026-10-20 01:59:59 +07:00
bflatastic db92c91678 fmt (#3015) 2026-10-20 01:48:35 +07:00
bflatastic ddc5fda173 path.join: a single path closed at coinciding ends is closed before its merged anchor is smoothed (#2982) 2026-10-20 01:48:35 +07:00
bflatastic bb32072be9 fmt (#2983) 2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor d41af02637 G1: Spacing Guides snap bounding-box resizes to equal gaps
Smart Guides spacing now applies when scaling with the Selection and Free Transform tools, not only on moves.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor 62910d0327 #2991: list Enter Isolation Mode in Keyboard Shortcuts
Mark object.isolate id as optional so runs_without_params includes it in the shortcut editor; engine already isolates the first selected object when id is omitted.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor db9fa8e0a9 Font menu: show 汉字 for the CJK script filter in Chinese UI (#2206)
Use a font-script i18n context so Simplified and Traditional Chinese label the Japanese-trait filter as Han characters without changing other locales.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
bflatastic 6d7a062227 .ai import: only ruler-units code 6 is pixels (#3015) 2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor 4b8e42e517 G1: path.join accepts corner or smooth on the merged anchor
Join can set the surviving junction point to a corner or smooth anchor via `to`, matching Illustrator's join choice without a combined Average+Join command.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor 5bf159d5d1 G1: show distance and angle on Pen, Line and Curvature rubber bands
Measurement labels while drawing now match the Measure tool (D, angle, dX/dY) when the preference is on.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor a48e65ba68 G9: map AI5_RulerUnits value 6 to pixels on .ai import
Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
nrq6993-a11yandCursor f557a1b6f3 G9: restore document ruler units from .ai editing data
Illustrator stores General ruler units in %AI5_RulerUnits; apply them on EPS/.ai import so rulers and length fields match the saved file.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:35 +07:00
bflatastic 5af05ab572 fmt (#2981) 2026-10-20 01:48:34 +07:00
bflatastic 197cf0ba67 Color books: one branch names an imported .acb or a temporary copy after the book (#3003) 2026-10-20 01:48:34 +07:00
nrq6993-a11yandCursor 0734836616 Fix #2681: distinguish Appearance from Pathfinder panel icons
The icon column used overlapping squares for both panels; Appearance now
reads as a stacked list so it is not confused with Pathfinder (storytold/vectorcraft#2681).

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:48:34 +07:00
bflatastic de8492b90c fmt (#3003) 2026-10-20 01:45:47 +07:00
nrq6993-a11yandCursor e975942be0 Fix #2996: color books copy to user folder under book title
When copying a loaded .acb into the user library, use the parsed book
title instead of a macOS sandbox temporary name (storytold/vectorcraft#2996).

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:43:58 +07:00
nrq6993-a11yandCursor 2f93923e60 G9: regression tests for AI editing-data path guides (#779)
Lock in (op) * guide import: plain, legacy-wrapped, and stroked paths stay guide geometry.

Co-authored-by: Cursor <[email protected]>
2026-10-20 01:43:57 +07:00
BFlat 68a99c359b Merge pull request #2013 from shorbank/feat/2008-color-picker-layout
#2008: improve color picker layout
2026-10-20 01:43:25 +07:00
BFlat 1a1b0dfd59 Merge pull request #2685 from krinry/feat/pencil-alt-smooth
G1: Pencil Alt key toggles to Smooth Tool (#2001)
2026-10-20 01:43:11 +07:00
Shayan Horbank 472a6d5359 UI: improve color picker layout (#2008)
Add a centered draggable header, stacked actions and aligned input groups.
Compact the color controls and use vertical scrolling in narrow windows.
Keep the screen sampler above the field and Tab through each color model
before the next. Confirmation, cancellation and live preview stay shared.

Preserve screen-reader button names and channel values, units and ranges.
Add pointer/key, layout, warning and AccessKit regressions, and update the
required progress notes without changing parity scores or estimates. Correct
the stale #1692 gap status while retaining the open Direct Selection gap.
Document dialog rendering, button sizing and the regression helpers.

Validation: cargo xtask ci passed all 10 steps with official Rust 1.99,
including strict Clippy, 8437 workspace tests and all WASM checks. All 17
targeted color picker tests passed. German, enlarged English, narrow-window
and color-warning UI renders were reviewed locally.

Fixes #2008
2026-10-20 01:30:19 +07:00
bflatastic 819da290f1 Pencil: Alt key toggles to Smooth Tool is kept with the tool's other options, and Georgian has its label (#2685) 2026-10-20 01:30:19 +07:00
bflatastic 303c5f8bca Pencil: Alt key toggles to Smooth Tool starts off, as Pencil Tool Options ships it (#2685) 2026-10-20 01:19:14 +07:00
krinry de985a46e6 Pencil: Alt key toggles to Smooth Tool (#2001) 2026-10-20 01:18:45 +07:00
BFlat 035f1cb2f6 Merge pull request #2951 from Hamza0101/inch-ruler-binary-ticks
Rulers: an inch is divided in half, not in ten
2026-10-20 00:02:15 +07:00
BFlat e52b07105d Merge pull request #3107 from FractalEncrypt/fix/status-bar-layout
Keep status-bar messages and readouts stable
2026-10-20 00:01:57 +07:00
Hamza 00f1231334 Tab stops: Shift snaps to the ruler's binary inch ticks
`shift_drag_snaps_a_stop_to_the_nearest_tick` landed while this branch was out
and asserts the tick an inch ruler used to carry: "the ticks are a tenth of an
inch (7.2 pt) apart", snapping 100 pt to 100.8. That tenth is what this change
removes -- an inch ruler divides the inch in half, not in ten.

The nearest tick to 100 pt is now 99 pt: 1 3/8 in, an eighth of an inch from
1 1/4. The test says so, and also asserts the result is a binary fraction of an
inch rather than a particular number, so it holds if the ladder is retuned.
2026-10-19 23:47:04 +07:00
Hamza c58c642f07 Rulers: the left ruler leaves out a label that would run into the one above
A label on the left ruler is spelled one character per line, so a long one is
tall: "-0.0625" is seven characters and reaches 60.5 pt down the ruler, while a
sixteenth-inch step leaves 54 pt at zoom 12. Binary steps made such labels
reachable -- the decimal ladder's "-0.1" was four characters in an 86 pt gap --
so the ruler could draw one label over another.

A label that would start inside the one above it is left out. Every tick still
draws, as do the labels that fit, and `vertical_label_height` gives the reach
of a label so the decision is one comparison.
2026-10-19 23:47:04 +07:00
Hamza 39b59b5730 Rulers: stop labelling an inch ruler at a sixteenth
Labelling to a hundred and twenty-eighth left the left ruler unreadable: it
spells a label one character per line, 8.5 pt apart, and at zoom 50 the step is
1/64 with labels 56.25 pt apart while "0.015625" needs 61.5 pt. At zoom 100 it
is "0.0078125" in the same space.

The label ladder stops at a sixteenth, as the report says Illustrator's does,
so zooming in past that leaves the labels where they are and only the ticks
between them grow denser -- down to a hundred and twenty-eighth, which is the
32nds, 64ths and 128ths the report expects to appear unlabelled. Every label is
at most the six characters of "0.0625", which fits the 50 pt the ladder leaves.
2026-10-19 23:47:04 +07:00
Hamza 2c6a2ad082 Rulers: an inch is divided in half, not in ten
Zooming in on an inch ruler stepped through 0.2, 0.1 and 0.05 of an inch:
`Unit::ruler_step` picked from one decimal ladder for every unit, and the
rulers divided whatever it returned by ten. A tenth of an inch is not a mark an
inch ruler carries (#2827).

Below an inch the steps are binary fractions now, 1/2 down to 1/128, and
`Unit::ruler_minor` gives the unlabelled ticks between two labels: an eighth of
the step on an inch ruler, so every tick is a binary fraction of an inch and
they stay about as dense as the tenths elsewhere. An inch and wider keeps the
shared ladder, and the metric units, points, pixels and picas keep their
tenths. Ruler-tick snapping follows the ticks that are drawn, which moves an
inch document's Shift-drag of a guide onto the same grid.

`ruler_label` took its decimals from `(-step.log10()).ceil()`, which writes
1/16 as "0.06" and 1/8 as "0.13" -- wrong values, not just rounded ones. It
uses the fewest decimals that write the step exactly, and drops trailing zeros
as `Unit::number` does, so a sixteenth-inch ruler labels the inch `1` rather
than spelling `1.0000` down six lines of the vertical ruler.
2026-10-19 23:47:03 +07:00
FractalEncrypt b09e23f4a1 M14: cache status-readout sample widths by display settings 2026-10-19 23:47:01 +07:00
FractalEncrypt 1e914c8d82 M14: document the status-bar readouts, helpers and regressions 2026-10-19 23:46:59 +07:00
FractalEncrypt 45d9a409d4 M14: keep status messages and readouts stable 2026-10-19 23:46:59 +07:00
BFlat 9d4675e7d4 Merge pull request #2962 from MiQ71/fix/issue-1299-dxf-circle-arc
DXF: export circular paths as native CIRCLE and ARC entities
2026-10-19 23:43:53 +07:00
BFlat d5dfbf4bc3 Merge pull request #3106 from FractalEncrypt/fix/canvas-reprojection-resize
Preserve cached canvas scale during viewport resizing
2026-10-19 23:43:36 +07:00
Mirko Kerezovic af48dda322 M4: address DXF circle export review (#1299) 2026-10-19 23:34:20 +07:00
Mirko Kerezovic b87cb157ed M4: export DXF circles and arcs as native entities (#1299) 2026-10-19 23:34:19 +07:00
bflatastic d2b927b57b Docs: the canvas-scale gap entry reads as the others do (#3106) 2026-10-19 23:34:18 +07:00
FractalEncrypt 04750ece1b M11: bound cached canvas dimensions to the renderer limit 2026-10-19 23:34:17 +07:00
FractalEncrypt c08955320c M11: document the canvas show entry point 2026-10-19 23:34:16 +07:00
FractalEncrypt 742a5c1af2 M11: preserve cached canvas scale during viewport changes 2026-10-19 23:34:16 +07:00
BFlat 1f80c28662 Merge pull request #3131 from storytold/fix/batch-zd-followups
Batch follow-ups: #3004 test prints, #3095 catalog rows, #3042 doc banners
2026-10-19 23:17:58 +07:00
bflatastic 6fed871f88 Docs: the live-text PDF Place notes go in the docs' own sections, without update banners (#3042) 2026-10-19 23:14:20 +07:00
bflatastic 3033fb9985 i18n: Move Anchor Point, the label of path.moveAnchor, in every catalog that has its sibling labels (#3095) 2026-10-19 23:14:09 +07:00
bflatastic 7e1095f9b1 Tests: the Type arrow tests drop their debug prints (#3004) 2026-10-19 23:14:09 +07:00
BFlat f378c5c836 Merge pull request #3042 from KnottyDyes/squad/place-pdf-keeps-text
PDF: placed and pasted pages keep live text
2026-10-19 23:13:58 +07:00
BFlat 55acd88d42 Merge pull request #3001 from ariymarkowitz/claude/changes
#2558: a drag over a wide blur draws it from the still frame's raster
2026-10-19 23:13:46 +07:00
BFlat 319221c087 Merge pull request #3102 from dotCooCoo/fix/effect-art-follows-fonts-and-color-settings
Effects: effect art redraws when fonts arrive or the color settings change
2026-10-19 23:13:31 +07:00
BFlat 9d43326ad7 Merge pull request #3095 from sungjea713/anchor-point-shift-moves-anchor
Anchor Point tool: Shift-drag on an anchor moves it, handles and all
2026-10-19 23:13:12 +07:00
BFlat 8baf5b1343 Merge pull request #3004 from sanyamsingh001/codex/r82-type-grapheme-boundaries
Keep Type arrows on grapheme boundaries in mixed-direction text
2026-10-19 23:12:59 +07:00
BFlat a60f8d7b9f Merge pull request #3120 from Janiz-zzz/fi-update-20261019
i18n(fi): translate 67 labels added since the Finnish review
2026-10-19 23:12:41 +07:00
BFlat 2c52bed0b5 Merge pull request #2961 from Hamza0101/encrypted-pdf-note
Exports: say that an encrypted PDF still differs, and test the clock fallback
2026-10-19 23:12:28 +07:00
BFlat de3dd27fda Merge pull request #2988 from 00xJS/fix/2675-pdf-invisible-text
PDF import: say when invisible text is left out (part of #2675)
2026-10-19 23:12:13 +07:00
BFlat 575bd0f924 Merge pull request #3006 from rotohun/fix/ai-hyphenation-tab-operators
eps: read Illustrator text with hyphenation and tab attributes
2026-10-19 23:11:54 +07:00
BFlat 1ef66c48c1 Merge pull request #2969 from dotCooCoo/fix/data-sets-file-dialogs-off-ui-thread
Variables: Import and Export Data Sets… show file dialogs on macOS and keep the window running
2026-10-19 23:11:36 +07:00
BFlat 35b5895183 Merge pull request #2964 from dotCooCoo/fix/pdfinfo-thumbnail-page
MCP: document.pdfInfo refuses a thumbnail that is not a page number
2026-10-19 23:11:22 +07:00
BFlat 0bd2a40128 Merge pull request #3074 from Silvertree2010/fix/3036-objects-as-guides-blocks-group-d
Stop locked guide objects from blocking marquee selection
2026-10-19 23:11:05 +07:00
BFlat 605597b09c Merge pull request #2968 from Silvertree2010/fix/h2813-deleting-an-anchor-from-a-closed
Clear dangling handles when deleting anchors from a closed path
2026-10-19 23:10:47 +07:00
BFlat 646563b033 Merge pull request #3115 from Silvertree2010/fix/h550-polygon-covers-rect-treats-a-sel
Reject self-intersecting polygons in polygon_covers_rect
2026-10-19 23:10:35 +07:00
BFlat 2861c113d6 Merge pull request #2821 from dotCooCoo/feat/linux-desktop-ai-eps-types
Linux: file managers offer VectorCraft for .ai, EPS and every other format Finder offers it for
2026-10-19 22:40:08 +07:00
KnottyDyes 48a0206adb PDF: placed and pasted pages keep live text
Honor the requested text mode in the single-page importer, as Open does, instead of forcing outlines for Place and PDF clipboard import.

Add synthetic regressions for bytes, unlinked paths, selected pages and explicit outlines. Update format parity and milestone evidence without changing scores.
2026-10-19 22:19:59 +07:00
Ari MarkowitzandClaude Opus 5.5 1fc3c23096 #2558: the coarser glow test also pans by fractions of a pixel
For review: after pans of (0.5, 0) and (37.3, -21.6), the Outer Glow
made coarser over its whole reach (scale 2 and 3) is still drawn from
the kept raster, placed at the nearest whole pixel as a full-size
raster kept whole is, and each frame equals a fresh renderer's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:57 +07:00
Ari MarkowitzandClaude Opus 5.5 7dc0c034d8 #2558: a coarser glow kept whole draws after a pan as made again
A test for review: an Outer Glow made coarser over its whole reach is
kept for any pan, since its grid starts at the reach's first whole
device pixel, which a whole-pixel pan moves with the art. After pans
that aren't multiples of its scale (2 and 3), the kept raster draws
exactly what a fresh renderer draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:57 +07:00
Ari MarkowitzandClaude Opus 5.5 be7fd07562 #2558: a drag over a wide blur draws it from the still frame's raster
An effect raster too large to keep whole (a wide blur, a shadow or glow,
or any blur zoomed in) is cropped to what can show in the view, or made
at a lower resolution and resampled to the view's pixels, and kept while
the view stays the same. Dragging other art over the object then draws
its effects from the still frame instead of previewing them at the
drag's lower resolution on every frame. An interaction's frames stay on
the render worker once it drew the frame shown, since only its renderer
holds those rasters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:57 +07:00
dotCooCoo 007f536c79 Effects: effect art redraws when fonts arrive or the color settings change
The renderer kept the art it made for an object's effects while the
object stayed the same. In the web build, type that a geometry effect
outlines, on its own, in a group or in a symbol's art, kept the
substitute font's outlines after its own font loaded. On the desktop,
Refresh Font List left them on a symbol instance whose art holds the
type. Objects with Revolve, Extrude & Bevel, Inflate or Rotate, and
objects and groups with a color adjustment, kept the colors they were
shaded or recolored with after the color settings changed. Live objects
already followed both.

The renderer now keeps that art with the inputs it keeps for the art
of live objects: the font generation when the art holds type, the art
of the symbols it shows, and the color settings. A temporary release
test timed the added key at 11 microseconds a frame for 400 type
objects with a drop shadow and 0.77 ms for 400 symbol instances of 100
paths each with a drop shadow, against warm frames of 5.3 and 72 ms.

Docs: docs/target-app-parity.md, docs/gaps.md.
2026-10-19 22:19:55 +07:00
sungjea kimandClaude Opus 5.5 515569470c Anchor Point tool: Shift-drag on an anchor moves it, handles and all
With the Anchor Point tool, a drag from an anchor pulls out smooth handles.
Shift held at the press now moves the anchor instead, its handles keeping
their offsets; Shift kept down during the drag keeps the move at 45° steps
(the constrain angle), released it frees it. A Shift-click that doesn't
move leaves the anchor as it was. A selected blend's spine points follow
the same rule (object.blend.spine.moveAnchor).

The move goes through a new engine command, path.moveAnchor
{id, subpath?, anchor, x, y}, since path.moveAnchors only acts on the
direct-selected anchors and the Anchor Point tool keeps no anchor selection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:53 +07:00
sanyamsingh001andCodex c07945b934 fix: keep wrapped Type fallback on grapheme boundaries
Co-authored-by: Codex <[email protected]>
2026-10-19 22:19:53 +07:00
sanyamsingh001andCodex de4c6b1a6d fix: keep Type arrows on grapheme boundaries
Co-authored-by: Codex <[email protected]>
2026-10-19 22:19:53 +07:00
Janiz-zzzandClaude Opus 5.5 ecaae7ee46 i18n(fi): translate 67 labels added since a466625c
Finnish rows for the labels the other catalogs gained since the Finnish
review merge: dimension tool modes and prompts, image trace blur modes,
the swatch library colour names (swatch context), batch action options
and two status messages. Workspace and perspective preset names stay in
English (MENU_KEEP_AS_IS), as in the other complete catalogs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:52 +07:00
Hamza 7bc7025a62 Exports: say that an encrypted PDF still differs, and test the fallback
Two review points on #2880.

The reproducibility claim needed qualifying. PDF encryption draws its AES
initialisation vectors and the file `/ID` from a random seed, which
`SOURCE_DATE_EPOCH` deliberately does not reach, so a PDF exported with a
password never repeats however the variable is set. The docs and the ROADMAP
line say so now.

The byte-identity test passes an explicit `created`, so it never exercised the
clock-or-variable fallback. `pinned_or` takes the environment and the clock as
arguments, and the new test shows the variable is preferred, that a blank,
negative or non-numeric value leaves it to the clock, and that a host with
neither has no date to record. The environment is injected rather than set: a
test that changes this process's environment is `unsafe` under edition 2024 and
would reach every other test running beside it, which is why `cursor_theme`
takes a lookup closure too.
2026-10-19 22:19:51 +07:00
00xJ0 8d123b19ca Test: check which text the PDF import keeps beside invisible text (#2675 review)
The visible line's text comes in as type and the invisible line's doesn't; as type or outlines,
all the art kept lies on the visible line's baseline, none on the invisible one's.
2026-10-19 22:19:50 +07:00
00xJ0 4aa4b0e70b PDF import: say when invisible text is left out (part of #2675)
Text in rendering mode 3 (invisible: a scanned page's searchable OCR layer) was dropped with no
warning, so a scanned PDF opened and saved again lost its searchable text without a hint. The
import now reports INVISIBLE_TEXT_NOTE when it leaves such text out, for type and outlines alike.

Keeping the text needs a kind of type that is kept but not drawn, and the PDF writer (krilla, in
craft-libs) to write it with `3 Tr`; its text API has fill, stroke and fill-and-stroke only.
2026-10-19 22:19:50 +07:00
Ronald Hunter 4614c08476 eps: read Illustrator text with hyphenation and tab attributes
Illustrator 6/8 EPS files whose PostScript can't be run (the Adobe_level2_AI5
procset is provided by the RIP, not embedded) are read through their private
layer data instead. That reader aborted the whole file when a text object
carried the hyphenation/tab-ruler operators `Th` (hyphen control), `XL`
(hyphenation language) and `Xb`/`XB` (a tab definition, with `Xe` descriptions
between them), reporting them as operators it doesn't know.

These carry no geometry, so drop them like the sibling type attributes the
reader already discards (§22 of the Illustrator File Format Specification)
rather than failing the import.
2026-10-19 22:19:49 +07:00
dotCooCoo 2ecc488111 Variables: Import and Export Data Sets… show file dialogs on macOS and keep the window running
The Variables panel menu called import_datasets and export_datasets
directly, so their file dialogs were asked for outside an entry and
shown in line. macOS shows no dialog in line, so the two items did
nothing there. On Linux the dialog held the window until it closed.

Both items now go through picks::button, as the Libraries panel
menu's Import Library… and Export Library… do. When the dialog
answers, the item runs again with the picked path. Errors still go
to the status bar.

variables_panel_data_sets_ask_off_the_ui_thread clicks both items
with dialogs shown as sheets, as on macOS, imports a CSV and exports the
data sets to the path the dialog answers.
2026-10-19 22:19:47 +07:00
dotCooCoo 6ed171c1b4 MCP: document.pdfInfo refuses a thumbnail that is not a page number
document.pdfInfo read thumbnail with as_u64, so a value such as -1,
1.5, "1" or true gave a reply without a thumbnail and no error. This
happened for a PDF and for a PostScript file.

A thumbnail that is present and not null must now be a whole number
from 1 to the page count. Any other value is the existing "thumbnail:
no page {n} (the PDF has {count})" error, or "(the file has 1)" for
PostScript, with the value as given. An absent or null thumbnail still
gives no thumbnail.
2026-10-19 22:19:44 +07:00
NetterSilverandClaude Haiku 5.5 3eed3be8e6 Stop locked guide objects from blocking marquee selection
Fixes #3036

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:42 +07:00
NetterSilverandClaude Haiku 5.5 85af04a83a Clear dangling handles when deleting anchors from a closed path
Found while testing crates/engine/src/cmd/path.rs:632 (without_anchors, closed-path branch uses keep instead of open_run)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:19:39 +07:00
NetterSilverandClaude Haiku 5.5 bdd5470dcf Reject self-intersecting polygons in polygon_covers_rect
Found while testing crates/geom/src/hit.rs:12-48

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:19:37 +07:00
dotCooCoo 157679c1a8 Linux: file managers offer VectorCraft for .ai, EPS and every other format Finder offers it for
Squashed from #2821 (its merges of main dropped).
2026-10-19 22:08:38 +07:00
BFlat 320bffb8ad Merge pull request #3066 from dotCooCoo/feat/objects-on-path
Objects on Path: attach objects to a path and spread them along it
2026-10-19 22:07:42 +07:00
BFlat 3d23fc5175 Merge pull request #3052 from dotCooCoo/feat/live-paint-selection-options
Live Paint: Live Paint Selection Tool Options set what a click selects and the highlight
2026-10-19 22:07:12 +07:00
BFlat 8c6085beca Merge pull request #3033 from dotCooCoo/feat/layers-locate-selection-option
Layers: with Locate Selected Objects on, the panel shows the row of art selected on the canvas
2026-10-19 22:06:38 +07:00
BFlat 6cb075b926 Merge pull request #3039 from dotCooCoo/feat/tabs-snap-to-unit
Tabs: Snap to Unit, or Shift held, puts a tab stop on the ruler's nearest tick
2026-10-19 22:06:23 +07:00
BFlat 0f27503f5b Merge pull request #3049 from dotCooCoo/fix/3d-strokes-outlined
3D and Materials: Extrude & Bevel and Inflate build a solid from each stroke's outline
2026-10-19 22:06:10 +07:00
BFlat 92ca32e55d Merge pull request #3048 from dotCooCoo/feat/data-set-applies-nothing-message
Variables: a data set with no value for any bound variable shows a status message
2026-10-19 22:05:53 +07:00
BFlat 37ee8bee3c Merge pull request #3040 from dotCooCoo/fix/hover-highlight-snap-to-grid
Smart Guides: object highlighting stays off with Snap to Grid or Pixel Preview
2026-10-19 22:05:36 +07:00
dotCooCoo 219253f163 Objects on Path: attach objects to a path and spread them along it
VectorCraft had no Objects on Path. A click with the Objects on Path
tool attaches the selected objects to the path clicked. The tool is in
Edit Toolbar's Modify section and the Blend tool's flyout, and Object ›
Objects on Path › Attach and the context menu with two or more objects
selected choose it. The objects and the path go into an Objects on
Path group with the path at its bottom.

- object.objectsOnPath.attach orders the objects column by column from
  left to right and top to bottom in each column. The first one sits
  on the path's first anchor, the last one sits on the end of an open
  path, and the rest are spread evenly by length; a closed path takes
  them all the way round. Each object's attachment point (Pivot, one
  of nine, default the center of its bounds without strokes) sits on
  the path, and the object is turned to the path there. An object that
  was rotated by an angle other than a multiple of 90° is first turned
  to the nearest multiple of 90°. Rotate tilts every object by that
  many degrees counter-clockwise. Symbol instances are measured by
  their art. Guides, compound paths, type, paths without length and
  graphs are refused with a reason.
- After every edit the objects go back on the path: editing the path
  moves them, an object moved on its own goes back, and objects moved
  into or out of the group in the Layers panel join the end of the
  path or leave it. Scaling, shearing or reflecting the whole group
  keeps each object's scale, shear or reflection, and the objects are
  spread evenly along the transformed path again. Undo takes the path
  and the objects back in one step, and a copied group stays live on
  its own path.
- object.objectsOnPath.options changes Pivot and Rotate, and
  object.objectsOnPath.expand leaves a plain group. Both act on the
  groups that `ids` names, or on the groups of the selection when
  `ids` is not given.
- The native file keeps the group live; SVG, PDF and the other exports
  write plain art. The tool highlights the path under the pointer, has
  a cursor entry, and Escape during a press cancels the click.

Part of #2545

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:32 +07:00
dotCooCoo e6c8ce1759 Live Paint: Live Paint Selection Tool Options set what a click selects and the highlight
Double-clicking the Live Paint Selection tool did nothing, and the tool
always selected faces and edges and highlighted them in red at 4 px.
It now opens Live Paint Selection Tool Options with Select Fills,
Select Strokes, Color and Width, the options the reference app's help
page on selecting objects lists.

- With Select Fills off, a click selects the edge nearest the pointer,
  as Alt-click does. With Select Strokes off, Alt-click selects the
  face under the pointer, as a click does. With both off, a click
  selects no face or edge.
- Color (a layer color or a custom color) and Width (1 to 20 px) set
  the highlight on the face or edge under the pointer.
- The defaults are the earlier behavior: both selections on, red
  (#ff2a2a) and 4 px. The options are tool options (tool.setOption
  {tool: "livePaintSelection"}): they last across tool switches and
  are saved with the preferences.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:31 +07:00
dotCooCoo e1761eb489 Layers: the panel options command describes when Locate Selected Objects opens nothing
The params text of ui.layersPanelOptions and the doc comment on
PanelOptions::locate_selected said that, with the option on, the panel
opens the rows around any object selected outside it and scrolls to its
row. When Show Layers Only, a search or the filter leaves the object
without a row, the panel opens no rows for it and scrolls as with the
option off. Both texts now describe that case, as docs/mcp.md does.
2026-10-19 21:51:29 +07:00
dotCooCoo 7f01abd687 Layers: Locate Selected Objects skips a selection recalled from the panel menu
With Locate Selected Objects on, choosing a saved selection in the panel
menu's Save Selection submenu opened the rows around the recalled object
and scrolled to it. A selection changed with the panel's own menu is not
located, and the submenu's recall now follows that rule as the other
panel menu items do.
2026-10-19 21:51:29 +07:00
dotCooCoo 729582dc78 Layers: Locate Selected Objects opens nothing for an object the panel does not list
With Locate Selected Objects on, an object selected on the canvas had the
layers and groups around it opened even when Show Layers Only, a search or
the filter left it without a row. The panel did not scroll to it, but the
rows stayed open, so they showed once the panel listed every row again.

The panel now opens the rows around the object only when the object has a
row once they are open. Otherwise it opens nothing and scrolls as with the
option off. The Layers panel entries in docs/mcp.md and
docs/target-app-parity.md describe this.
2026-10-19 21:51:29 +07:00
dotCooCoo 7c64ecd998 Layers: with Locate Selected Objects on, the panel shows the row of art selected on the canvas
By default the Layers panel opens the layers around a row and scrolls
the row into view when its highlighted rows change, for example after
a click in the panel, a new layer or Locate Object. When no row is
highlighted and a selection makes another layer or sublayer the
active one, the panel does the same for that layer's row. Selecting
art on the canvas or by a command highlights no row, so by default
the panel does not open the layer or sublayer that holds the art or
the groups around it, and does not scroll to the art's row.

Panel Options gets Locate Selected Objects, off by default and kept
with the UI state. With it on, when the last selected object changes
on the canvas or by a command, the panel opens the layers and groups
around it and scrolls to its row, as after Locate Object, but does not
highlight the row. When the selection also changes the active layer,
the panel scrolls to the object's row instead of the layer's row.
When Show Layers Only, a search or the filter leaves the object
without a row, the panel scrolls as it does with the option off. A change made with the panel's own
rows, buttons or menu is not located, and a change made during a drag
is located when the drag ends. Locate Object is unchanged.

On a layer of 5,000 groups of 10 paths with every row open, the first
panel frame after a selection change took a median of 1.6 ms with the
option on and took a median of 1.4 ms with it off, in a release build.

Docs: docs/mcp.md (locateSelected), docs/target-app-parity.md.
2026-10-19 21:51:29 +07:00
dotCooCoo 55f98095f3 Tabs: Snap to Unit, or Shift held, puts a tab stop on the ruler's nearest tick
A tab stop placed or dragged on the Tabs panel's ruler always landed
at a fractional position, and the ruler was numbered in inches with a
tick every 18 pt whatever the document's units. The reference app
snaps stops to the ruler units with Snap To Unit in the panel menu or
with Shift held during a drag, and numbers its tab ruler in the
document's units.

The ruler is now numbered in the General unit with the canvas rulers'
label step at the ruler's scale, and has a tick every tenth of a step.
The panel menu has Snap to Unit, checked while it is on. With it on,
or with Shift held, a stop placed by a click or dragged on the ruler
lands on the nearest tick, through Unit::snap_to_ruler_tick as a
Shift-dragged ruler guide does. A snapped click that lands on an
existing stop's tick selects that stop and records no undo step. The
stops still change through text.tabs.set. The reference app's page
names Shift for a drag only; Shift also snapping a click, and a
snapped click onto a stop's tick selecting that stop, are VectorCraft's
own rules.

Docs: docs/target-app-parity.md.
2026-10-19 21:51:27 +07:00
dotCooCoo ac1b380b40 3D and Materials: Extrude & Bevel and Inflate build a solid from each stroke's outline
Extrude & Bevel and Inflate built one solid from the path, painted with
the object's top fill or, without a fill, its top stroke. A filled and
stroked object lost its stroke, and a stroked open path was extruded as
the area it would enclose if it were closed.

Each visible stroke now builds a solid from its outline in its own
paint, beside the solid the top fill builds from the path, as Rotate
already turns each stroke's outline. When the fill and the strokes are
opaque with normal blending, the faces of all the solids are drawn in
one back-to-front order; otherwise each solid is drawn on its own, in
the order of the appearance. Under a light that is not white, a surface
drawn as one composite or with an opacity mask is also drawn on its
own. In that one order a flat cap turned toward the viewer is drawn
after every other face, untwisted solids included, so the stroke's
front cap covers the fill's walls inside it. The solids share one set
of bounds, and with Perspective one eye sees every inflated solid. A
stroke whose outline is past the 2,048-point limit is left out.

Inflate's cells cut by a hole now pass only the nearby contours to the
boolean. An inflated, outlined 72 pt "VectorCraft" takes about 8 ms
instead of 34 ms, and 66 ms with a 3 pt stroke.

Docs: docs/three-d.md (Fills and strokes), docs/target-app-parity.md.
2026-10-19 21:51:26 +07:00
dotCooCoo c3a6aa0a29 Variables: a data set with no value for any bound variable shows a status message
Choosing a data set in the Variables panel, stepping through data sets
with the panel's arrows, and running `dataset.select`, `dataset.next`
and `dataset.prev` applied nothing and showed no message when no object
was bound to a variable that the data set has a value for. This
happens, for example, after Import Data Sets… defines new variables
without binding them.

The app now shows "nothing in data set A was applied: no object is
bound to a variable it has a value for" in the status bar, and the three
commands add it to the reply as `warning`. A data set with a value for
a bound variable gives no warning even when every object bound to it is
locked; `skipped` counts those objects. What applying a data set does
is unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:26 +07:00
dotCooCoo 1d214956f9 Smart Guides: object highlighting stays off with Snap to Grid or Pixel Preview
The reference app's help says Smart Guides cannot be used while Snap to
Grid or Pixel Preview is on, even with the menu command selected. The
Selection tool's move already leaves Smart Guides out under Snap to
Grid, but the canvas still drew the Object Highlighting outline under
the pointer with either view on.

The hover outline now draws only with Smart Guides and Object
Highlighting on and with Snap to Grid and Pixel Preview off.
2026-10-19 21:51:25 +07:00
nrq6993-a11yandCursor 32a322531a G1: fix guide.move preview copy flag and ruler-drop Alt handling.
Align ruler-drop detection with source orientation and alt-flip readout tests with relative origins.

Co-authored-by: Cursor <[email protected]>
2026-10-19 07:51:20 +03:00
nrq6993-a11yandCursor 0d09d6a188 Docs: mark existing ruler-guide Alt orientation as done in ui-parity
Co-authored-by: Cursor <[email protected]>
2026-10-19 01:46:47 +03:00
nrq6993-a11yandCursor 73eaa84d34 G1: Alt-drag an existing ruler guide to swap its orientation
Match the public shortcut reference: Alt while dragging converts horizontal and vertical, with snapping on the new axis; guide.move accepts vertical on the index form.

Co-authored-by: Cursor <[email protected]>
2026-10-19 01:46:38 +03:00
Dylan M. Taylor 923a3ddc09 docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:47 -04:00
Dylan M. Taylor a466625c89 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:31:54 -06:00
BFlat 73f921fa0a Merge pull request #2947 from Hamza0101/reproducible-export-dates
Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
2026-10-19 00:52:49 +07:00
BFlat fb18533e12 Merge pull request #2677 from dotCooCoo/feat/rotate-view-dial
Rotate View: a dial shows the angle while a drag rotates the view
2026-10-19 00:52:33 +07:00
BFlat 56be31fe9b Merge pull request #2670 from dotCooCoo/fix/joiners-stay-with-their-run
Type: a zero width non-joiner is shaped in the face of the letters around it
2026-10-19 00:52:19 +07:00
BFlat a23d7a827f Merge pull request #2948 from dotCooCoo/fix/open-pages-naming-every-page
PDF/.ai import: a .ai saved without PDF compatibility opens when pages names every page
2026-10-19 00:52:03 +07:00
Hamza 1c80b23745 Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files
record the wall-clock time they were made in, so the same commands run twice
gave different bytes although the pixels matched (#2880):

  PDF   /CreationDate and /ModDate   D:20261018155816 vs D:20261018155817
  PNG   the `Creation Time` tEXt entry
  .vectorcraft  one byte of the stored date
  SVG   carries no time, and already repeated

`metadata::output_time` is the time a file records: `SOURCE_DATE_EPOCH` when it
holds a non-negative whole number of seconds, else the clock, as the
reproducible-builds specification defines it. A date a command is given
(`file.new {created}`, a save's `modified`) still wins over the variable, and a
blank, negative or non-numeric value is ignored so that an export is never
refused over the environment.

`metadata::now_unix` keeps the real clock and now has exactly two callers, both
of which must not be pinnable from the environment: the recovery heartbeat, and
the seed PDF encryption draws on.
2026-10-19 00:37:55 +07:00
bflatastic c9a2491ef7 Rotate View: a dial shows the angle while a drag rotates the view
Squashed from #2677 (its merge of main dropped).
2026-10-19 00:37:53 +07:00
bflatastic 2ae992870b Type: a zero width non-joiner is shaped in the face of the letters around it
Squashed from #2670 (its merge of main dropped).
2026-10-19 00:37:50 +07:00
dotCooCoo cec31c5f4a PDF/.ai import: a .ai saved without PDF compatibility opens when pages names every page
A .ai saved without PDF compatibility has only a placeholder page in
its PDF part. document.open reads such a file from its editing data,
but with `pages` given it failed with "the file holds only a
placeholder page", even for "1" on a file of one page.

When `pages` names every page of the file, document.open now reads
the editing data as it does without `pages`.

Docs: docs/mcp.md.
2026-10-19 00:37:38 +07:00
BFlat 3b527ca339 Merge pull request #2953 from nrq6993-a11y/stream/vc-feat3-1753-workspace-dock-width
#1753: saved workspaces restore dock panel width
2026-10-19 00:37:00 +07:00
BFlat 4f18f1c860 Merge pull request #2952 from nrq6993-a11y/stream/vc-feat-g1-busy-cursor
G1: show the system busy cursor during background open, save and export
2026-10-19 00:36:46 +07:00
bflatastic b529f356cc Tests: the dock-width test builds its UiState with the width, as clippy asks (#2953) 2026-10-19 00:28:48 +07:00
bflatastic 048875cc4e fmt (#2952) 2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 57ddb71d97 #1753: saved workspaces restore dock panel width
Persist the main dock column width in workspace snapshots and reapply it when switching layouts.

Co-authored-by: Cursor <[email protected]>
2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 49f7f5d7d8 G1: busy cursor during background open, save and export
Co-authored-by: Cursor <[email protected]>
2026-10-19 00:26:27 +07:00
BFlat aa9c70a7b1 Merge pull request #2956 from storytold/fix/zh-hant-param-errors
i18n: Traditional Chinese and Georgian rows for the parameter-check and pdfInfo errors (#2930, #2784, #2914)
2026-10-19 00:23:15 +07:00
bflatastic 278fba490a i18n(ka): the pdfInfo thumbnail page error (#2914) 2026-10-19 00:16:42 +07:00
bflatastic d807236bf2 i18n(zh-hant): the shared parameter-check errors, now that Traditional Chinese translates every message (#2930, #2784)
#2784 made zh-hant a language that translates every status and error message while #2930 added the shared parameter-check errors to the others; main's shared_param_check_errors_are_translated failed on zh-hant.
2026-10-19 00:15:35 +07:00
BFlat 9c1d1d56ed Merge pull request #2944 from dotCooCoo/fix/save-for-web-preview-folder-per-run
Save for Web: each browser preview is written to its own folder
2026-10-19 00:10:12 +07:00
BFlat 53bd154059 Merge pull request #2938 from dotCooCoo/fix/legacy-ai-names-windows-1252
Legacy .ai export: the title and spot color and gradient names are written in Windows-1252
2026-10-19 00:09:56 +07:00
BFlat 1f2e90a7b3 Merge pull request #2937 from dotCooCoo/fix/ai-image-left-out-warns
.ai import: the editing data reader warns about each image it can't read
2026-10-19 00:09:38 +07:00
BFlat c7f748c75c Merge pull request #2936 from dotCooCoo/fix/editing-data-hash-reads-utf16-desc
PDF and .ai import: editing data with a UTF-16 description is checked against its pages
2026-10-19 00:09:23 +07:00
BFlat 4de769175e Merge pull request #2935 from dotCooCoo/fix/issue-2830
Legacy .ai export: an F line follows each %AI5_EndRaster
2026-10-19 00:09:07 +07:00
BFlat 9a8e2ac6a0 Merge pull request #2930 from dotCooCoo/fix/number-param-error-translated
i18n: number, object id and date parameter errors are translated
2026-10-19 00:08:55 +07:00
BFlat 419ed87cff Merge pull request #2929 from dotCooCoo/fix/anchor-commands-refuse-a-bad-subpath
Paths: anchor commands refuse a subpath that is not a whole number
2026-10-19 00:08:43 +07:00
BFlat eeb9513bbb Merge pull request #2921 from dotCooCoo/fix/links-on-mapped-drives
Links: a document opened from a mapped drive finds links on its share
2026-10-19 00:08:31 +07:00
BFlat c092ca1a48 Merge pull request #2917 from dotCooCoo/fix/menu-commands-refuse-text-numbers
Transform Each, Rasterize, Mosaic, Trim Marks and Rearrange: a number given as text is refused
2026-10-19 00:08:16 +07:00
BFlat ba2a11a807 Merge pull request #2914 from dotCooCoo/fix/pdfinfo-reads-postscript
MCP: document.pdfInfo answers one page for an EPS, a .ps file or a PostScript .ai
2026-10-19 00:08:04 +07:00
BFlat f1757c7bda Merge pull request #2907 from dotCooCoo/fix/eps-tiff-predictor-all-depths
EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth
2026-10-19 00:07:47 +07:00
BFlat e616f1fe5c Merge pull request #2784 from dotCooCoo/feat/zh-hant-messages
i18n: Traditional Chinese translates status-bar and error messages
2026-10-19 00:07:32 +07:00
BFlat 647d51eaf6 Merge pull request #2765 from dotCooCoo/feat/open-targa-files
File formats: Targa (.tga) files open and place
2026-10-19 00:07:17 +07:00
BFlat 0d34a58b7b Merge pull request #2943 from youngeuibae/page-api
web: window.vectorcraft, the control channel for the page
2026-10-19 00:07:07 +07:00
BFlat 3a685eae61 Merge pull request #2939 from MortenHusted/feat/select-by-color
Select: select.color picks objects by fill and stroke colour, no object needed
2026-10-19 00:06:55 +07:00
BFlat e4d94109a2 Merge pull request #2922 from dotCooCoo/fix/issue-2871
Type: a click away from the text being edited ends editing without making new text
2026-10-19 00:06:44 +07:00
BFlat 4a90d13fc0 Merge pull request #2916 from dotCooCoo/fix/issue-2881
Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
2026-10-19 00:06:26 +07:00
BFlat 8a2d0db225 Merge pull request #2915 from EricKuck/fix/ai-outer-glow-dilation
Effects: an Outer Glow grows its solid silhouette by a quarter raster pixel before the blur
2026-10-19 00:06:16 +07:00
BFlat e358bbfdfb Merge pull request #2928 from nrq6993-a11y/stream/vc-issue-2719-wysiwyg-zoom
Fix #2719: honor Display Print Size for status-bar 100% zoom
2026-10-19 00:06:00 +07:00
BFlat 7d7853d7fd Merge pull request #2931 from MortenHusted/fix/raster-scale-only
CLI, MCP: the raster scale no longer resizes DXF exports
2026-10-19 00:05:46 +07:00
BFlat 0a14001126 Merge pull request #2911 from nrq6993-a11y/stream/vc-issue-2646-floating-panel-opaque
Fix #2646: keep undocked shared-dock panels opaque
2026-10-19 00:05:29 +07:00
BFlat 910e33b796 Merge pull request #2927 from nrq6993-a11y/stream/vc-issue-2727-mask-editing-dim
Fix #2727: opacity-mask editing no longer dims the canvas
2026-10-19 00:04:45 +07:00
BFlat 9b64fce9ec Merge pull request #2913 from Higgs32584/fix/2707-cmyk-icc-output-tags
CMYK profiles carry every tag an ICC output profile requires (#2707)
2026-10-19 00:04:16 +07:00
BFlat cd35c27a53 Merge pull request #2941 from devius/ka-fixes
i18n(ka): Letter and Word Spacing as in the other Craft apps; "weren't read" in the catalog's usual form
2026-10-19 00:04:00 +07:00
dotCooCoo ab01a1a927 Save for Web: each browser preview is written to its own folder
Preview in Browser wrote every page to one folder,
VectorCraft Save for Web in the system's temporary folder. A second
preview of a document with the same title, from the same app or from
another one running, overwrote the first preview's page, and any image
file of the same name (PNG-8 and PNG-24 both write .png), before the
browser read them. The same overwrite made
dialogs::tests_save_for_web::preview_in_browser_opens_the_page fail now
and then, when another Save for Web test in the same binary, or a test
run in another checkout on the machine, previewed Untitled-1 at the
same time.

Each preview now goes to a new folder inside that one, named after the
process id and a count of the process's previews. A name that an
earlier process with the same id left there is skipped for the next
count. The page keeps its name and its images folder. A preview first
removes the entries of VectorCraft Save for Web that were last changed
an hour ago or earlier, which also clears the files the single folder
held before.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:53 +07:00
dotCooCoo d054b5b4c7 Legacy .ai export: the title and spot color and gradient names are written in Windows-1252
Saving a version 7 .ai wrote the title, spot color names and gradient
names as the UTF-8 bytes of their characters: a spot color named Café
was written (Caf\303\251) in the header's custom colors and in each x.
Type and, since #2722, layer names are written in Windows-1252, the
encoding the file installs with TE.

These names now go through #2722's Windows-1252 path, so Café is
written (Caf\351). A character outside Windows-1252 is written as ?,
with one warning for all of these names. A spot color keeps one name
in the header, in each x and X and in gradient stops, and a gradient
keeps one name in its definition and in each Bg. Two spot colors or
two gradients whose names become the same that way are numbered (? 2).

Docs: docs/file-format-parity.md.
2026-10-19 00:03:52 +07:00
dotCooCoo ba32ef5b09 .ai import: the editing data reader warns about each image it can't read
An image in a .ai's editing data whose operands had no matrix, fewer
than 11 numbers, or a matrix of other than six numbers was left out
with no warning. So was one whose matrix maps it onto a line or a
point. The reader's warnings did not mention such an image, while the
reader warned "an image VectorCraft can't read was left out" for an
image whose size or bit depth it can't use.

Each of those exits now adds the same warning, as does the one where
the decoded pixels don't make an image.
2026-10-19 00:03:51 +07:00
dotCooCoo f0c2d8c890 PDF and .ai import: editing data with a UTF-16 description is checked against its pages
A PDF or PDF-compatible .ai saved with Preserve Editing carries the
native document as an embedded file, with a hash of its pages at the
end of the file's /Desc. The import read /Desc with from_utf8_lossy and
looked for "hash ". /Desc is a PDF text string, which may be written as
UTF-16BE with a byte order mark (ISO 32000-1 7.9.2.2). Written that
way it holds no UTF-8 "hash ", so the import found no hash, took the
editing data as intact and reopened the native document with no
warning, even after another app had changed the pages.

/Desc is now read with text_string, the importer's decoder for text
strings, so the hash is found in UTF-16BE, UTF-8 and PDFDocEncoding.
Changed pages open as artwork with the "changed in another app"
warning. The embedded file's /UF and /F names are decoded the same
way, so editing data named only in UTF-16BE is found.
A /Desc without a hash is still trusted, as before.
2026-10-19 00:03:51 +07:00
dotCooCoo e5c9054324 Legacy .ai export: an F line follows each %AI5_EndRaster
alongtheblue-sketch took 0.12.0's version 7 output of the SVGs in #2692
and #2830, edited it by hand and opened it in the reference app
(https://github.com/storytold/vectorcraft/pull/2801#issuecomment-6244853548).
Writing the image lines as #2801 does, with the bracketed matrix and
the 60-digit data lines, did not change the result there. With one
line, F, added after %AI5_EndRaster, the reference app opened the image
in its clipping group and the art after it in both files.

The writer now writes F after every %AI5_EndRaster: clipped, grouped
and loose images, flattened images and images in pattern tiles. No path
is open at that point, so the line paints nothing. VectorCraft's reader
opens the files with the same objects as before, and the file's
PostScript prolog draws the same page.
2026-10-19 00:03:50 +07:00
dotCooCoo 9fe0f03de2 i18n: number, object id and date parameter errors are translated
The errors of the parameter checks that many commands share had no
@msg row in any catalog: "`{key}` must be a number, not {v}" from
check_numbers (object.move, object.rotate, object.setBounds and the
other callers), "missing integer `{key}`" from index_param in
cmd/appearance.rs (appearance, effect and freeform gradient commands),
the three object id errors of checked_ids_param and checked_id, and
the Unix seconds error of date_param. A template
matches only the whole text, so the row for "`{key}` must be a
number" did not match the longer check_numbers message. These errors
showed in English in the status bar, and "no such object id {value}"
matched the general "no such object {0}" row and showed half in
English. The scan in complete_languages_translate_every_message does
not read messages built with bad(), so that test did not report them.

Each of the 6 messages now has a row in the 18 catalogs that translate
every status and error message and in Bulgarian, Danish, Indonesian and
Dutch, and "missing number `{key}`" has a row in Danish and Indonesian.
No English string changes.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:48 +07:00
BFlat 429e147164 Merge pull request #2940 from ElPaule/i18n/de-missing-entries
i18n(de): add missing PSD error messages and effect Soft entry
2026-10-19 00:03:48 +07:00
dotCooCoo c0ec14b86d Paths: anchor commands refuse a subpath that is not a whole number
path.curvatureEdit, path.removeAnchor, path.convertAnchor,
path.reshapeSegment, path.split, path.setHandle and path.insertAnchor
read subpath as an unsigned integer and fell back to 0 for any other
value. A subpath of 1.5, -1 or "1" edited subpath 0 and the command
reported success. path.split read anchor and segment with as_u64 too
and treated any other value as absent, so a bad anchor beside a good
segment split at the segment.

These commands now refuse a subpath that is not a whole number of 0 or
more with "missing integer `subpath`", the error path.curvatureEdit,
path.removeAnchor, path.convertAnchor and path.reshapeSegment already
give for a bad anchor or segment, and path.split refuses such an anchor
or segment too. Without a subpath, or with null, they edit subpath 0 as
before. The document and the undo history stay as they were after each
error. The params text of the six commands that listed subpath as
required now marks it optional.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:47 +07:00
dotCooCoo 89a73abc38 Links: a document opened from a mapped drive finds links on its share
craft-docpath follows a network path only on the share the document is
on, and its caller contract asks for a document opened from a mapped
drive to be passed by its UNC path. fileio::document_path passed the
drive path as given, so a document opened as Z:\jobs\doc.vectorcraft
treated a link stored as \\server\share\img\logo.png, on the share
behind Z:, as on another computer: the image was missing, and
document.open, links.check and links.info gave refused: "the path is on
another computer".

On Windows, when a stored path is refused as on another computer and
the document is on a drive, document_path now reads the final path of
the document's folder (std::fs::canonicalize). When that names a share
(\\?\UNC\server\share\...), the path is checked again against the
document's path on that share. Links on other computers stay refused,
and paths that were followed resolve as before. Opening a document,
Link Info, links.check and the linked-file watch all use it. Other
systems are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:47 +07:00
dotCooCoo cc8944ad2a Transform Each, Rasterize, Mosaic, Trim Marks and Rearrange: a number given as text is refused
Transform Each, Rasterize, Create Object Mosaic, Create Trim Marks and
Rearrange All Artboards read their optional numbers with f64_or, which
falls back to a default for a value that is not a JSON number.
object.transformEach {"scaleH": "50"} left the selected objects at
their size and reported success. Rasterize did the same with ppi,
Create Object Mosaic did the same with width, height, spacingX and
spacingY, Create Trim Marks did the same with offset, length, bleed and
weight, and Rearrange All Artboards did the same with spacing. Crop
Image cropped to the artboard when rect was a string or another value
that is not an array.

These commands now fail with "`<key>` must be a number, not <value>"
for a value that is not a number or null, as the transform commands
do, and change nothing. Transform Each checks scaleH, scaleV, moveH,
moveV and rotate. Crop Image refuses a rect that is not an array of
four numbers with "rect must be [x, y, width, height]", the message it
already gave for an array of the wrong length; a null rect crops to the
artboard, as a missing one does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:46 +07:00
dotCooCoo 05264b2801 MCP: document.pdfInfo answers one page for an EPS, a .ps file or a PostScript .ai
document.pdfInfo refused PostScript (an EPS, a .ps file, or a .ai in
PostScript form), mostly with "this is a PostScript file ..., not a
PDF", although document.open and file.place read the same file
through the EPS reader as one page.

It now answers {pages: 1, needsPassword: false, pageInfo: [{width,
height, rotation: 0, boxes: {}}]}. The page is the file's bounding box
(%%HiResBoundingBox when it has one, a US Letter page without one),
read from its comments without running its program. thumbnail: 1 is a
PNG of what that page prints, read by the EPS reader without any
editing data the file carries. A program whose header is only %! is
read as PostScript, as clipboard.importEps reads it.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:45 +07:00
dotCooCoo 372887fc6c EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth
The PostScript interpreter that reads EPS and PostScript .ai files
accepts a FlateDecode or LZWDecode filter with Predictor 2 at
BitsPerComponent 1, 2, 4, 8 and 16, but undid the horizontal
differencing only at 8 bits. At the other depths the filter gave back
the stored differences, and the image drew them instead of its
samples.

The predictor is now undone at every depth it accepts, as ISO 32000-1
§7.4.4.4 describes: each component adds the same component of the
pixel to its left, within its bit width, and each row starts again.
16-bit components add as big-endian 16-bit numbers. The padding bits
at the end of a row stay as they are, and 8-bit data takes the same
path as before. The PNG predictors are unchanged.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:43 +07:00
dotCooCoo 841fc0cd51 i18n: Traditional Chinese translates status-bar and error messages
Squashed from #2784 (its merge of main dropped).
2026-10-18 23:50:58 +07:00
dotCooCoo d7e7066251 File formats: Targa (.tga) files open and place
Squashed from #2765 (its merge of main dropped).
2026-10-18 23:50:58 +07:00
focuerandClaude Opus 5.5 dc7216f0e4 web: rustfmt window.vectorcraft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:50:58 +07:00
MortenHusted 36aecff81d Select: select.color reads compound paths and empty type as document.inspect does
A compound path holds its members, so select.color passed over it and
looked at the members instead, whose paint inspect doesn't report: a
black compound path (art with holes, as PDFs bring in) never matched
"#000000". It now matches by its own fill and stroke, and its members
never do. Type without characters reads as the default character fill,
as inspect reports it (CodeRabbit on #2939).
2026-10-18 23:50:58 +07:00
focuerandClaude Opus 5.5 9dbab52168 web: window.vectorcraft, the control channel for the page
The same JSON methods as the desktop control server (engine.execute, ui.inspect, ui.screenshot, …),
as promises on window.vectorcraft, like FilmCraft's window.filmcraft: an agent driving the editor in a
browser tab or an <iframe> uses the same commands as the CLI and the MCP server. openFile opens
bytes the way a dropped file opens; captureDownloads keeps saves in the page for readFile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:50:58 +07:00
dotCooCoo 09a6fbe470 Type: a click away from the text being edited ends editing without making new text
While a Type tool edited text, a click on blank canvas ended the
editing and also placed new point type at the click. Area Type and
Type on a Path did the same with a click on a path.

Such a click now only ends the editing. The typing session is
committed, empty point type that a click placed is discarded as before,
and the Type tool in use stays active; the next click makes new type.
The same applies when the editing began with a double-click from a
selection tool. A click on other type still edits it at once, and a
drag that draws an area-type frame still makes the frame.
2026-10-18 23:50:57 +07:00
dotCooCoo b30d80e056 Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
In number fields, Up/Down and a wheel notch added a whole step and kept
the fraction, so 5.1 mm stepped up to 6.1 mm. A plain step now goes to
the next whole unit in its direction (5.1 goes up to 6 and down to 5; 5
goes up to 6 and down to 4), and a Ctrl/Cmd step goes to the next tenth,
in the field's own unit. Repeated presses or notches of one size add or
take one step each, and presses and notches are applied in the order
they came. A Shift step still adds or takes ten and keeps the fraction.

The number fields that widgets.rs draws for the panels, the Control bar
and the dialogs (unit, percent, degree and count fields) all use this
step (widgets::step_value).
2026-10-18 23:50:57 +07:00
EricKuckandClaude Fable 5.1 1b8e3d9ae1 Outer Glow grows its solid silhouette by a quarter raster pixel before the blur, as the glows AI files export measure
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 23:50:57 +07:00
bflatastic 12d107cc56 fmt (#2928) 2026-10-18 23:50:57 +07:00
MortenHusted eba9abc21e CLI, MCP: the raster scale no longer resizes DXF exports
`vectorcraft-cli run/convert --scale` and the MCP export tool's `scale`
are raster pixels per point, but they were passed to every export, and
DXF reads `scale` as drawing units per unit: a 2x PNG next to a DXF
doubled the drawing and left it unitless ($INSUNITS 0). They now go
only to raster formats. DXF's own scale stays reachable through the
format options.
2026-10-18 23:50:57 +07:00
bflatastic 88494319da fmt (#2911) 2026-10-18 23:50:57 +07:00
MortenHusted d25289a3be Select: select.color picks objects by fill and stroke colour, no object needed
Scripts had to know an object id before they could select art by colour
(select.same.* and select.magicWand start from one). select.color
{fill?, stroke?} takes colours as paint.setFill does and selects the
objects whose fill and stroke read as them in #rrggbb, as
document.inspect reports colours, with Select > Same's selection rules.

The label is translated in each of the 23 catalogs, which
complete_languages_translate_every_menu_string requires.
2026-10-18 23:50:57 +07:00
nrq6993-a11yandCursor 2025c357d9 Fix #2719: honor Display Print Size for status-bar 100% zoom
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:50:57 +07:00
nrq6993-a11yandCursor 16dcaa425b Fix #2727: opacity-mask editing no longer dims the canvas
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:48:57 +07:00
nrq6993-a11yandCursor bf19ada5e1 Fix #2646: keep undocked shared-dock panels opaque
Floating groups on the canvas now always get a panel-colour backdrop from layout geometry when egui has not registered the area yet, and panel bodies paint their own fill so empty regions do not show through.

Co-authored-by: Cursor <[email protected]>
2026-10-18 23:48:57 +07:00
Higgs32584andClaude Opus 5.5 c5798f446e CMYK profiles carry every tag an ICC output profile requires (#2707)
The built-in CMYK profiles embedded in CMYK exports had only the
perceptual and colorimetric tables. ICC.1 requires an output profile to
also have the saturation tables (A2B2, B2A2) and the gamut tag (gamt).
Windows colour management rejects the profile without them ("Bitmap color
context is not valid"), so Microsoft Photos couldn't show a CMYK TIFF
that embedded it, while the RGB export opened fine.

Write the saturation tables (same as colorimetric) and a 9x9x9 gamut
table (in gamut where a Lab colour separates and returns within dE 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:48:56 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 4675a1633e i18n(ka): Letter and Word Spacing as in the other Craft apps; "weren't read" in the catalog's usual form
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 23:48:55 +07:00
ElPaule d99b04e594 i18n(de): add missing PSD error messages and effect Soft entry 2026-10-18 23:48:54 +07:00
BFlat 0f6154e7b9 Merge pull request #2934 from devius/fonts-georgian-pin
Release: embed the current craft-fonts (adds Noto Sans Georgian, craft-fonts#27)
2026-10-18 23:45:27 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 fc594b51ee Release: build the web version with craft-fonts too (web-craft-fonts)
Without it the shared release workflow builds the web release without CRAFT_FONTS_DIR, so
web-fonts.txt's startup faces (Arabic, Thai, Georgian) are not served (CodeRabbit review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 20:29:04 +04:00
Dylan M. Taylor 0d2eeed995 docs: add SonarCloud and CodeRabbit badges and standardize to flat-square 2026-10-18 11:05:48 -04:00
BFlat cdba0aac03 Merge pull request #2900 from nrq6993-a11y/stream/vc-issue-2723-reshape-falloff
Fix #2723: Reshape tool falloff follows the path, so nodes stretch progressively
2026-10-18 20:14:02 +07:00
BFlat f44c3e9593 Merge pull request #2895 from Silvertree2010/fix/h2559-unit-named-ft-resolves-to-feet-i
Resolve the "ft" unit suffix to Feet in Unit::named
2026-10-18 20:13:47 +07:00
BFlat c965f49fda Merge pull request #2905 from storytold/fix/2850-pencil-redraw
Pencil: a stroke that starts and ends on a selected path redraws the section between (Edit Selected Paths, #2850)
2026-10-18 20:13:22 +07:00
BFlat a10604bbc6 Merge pull request #2904 from storytold/fix/2860-recent-cards
New Document: Recent and Saved cards keep the orientation they were made in (#2860)
2026-10-18 20:13:13 +07:00
bflatastic 4f526b4e3f Reshape: the lengths along the path are measured once per drag, and the default reach stays the path's size (#2900)
anchor_arc_distance measured every segment again for each anchor, so a reshape cost the square of the anchor count; path.reshape now measures the positions once, and the two helpers only it used are gone. The default radius stays half the subpath's bounding-box diagonal, now measured along the path: half its length reached the far corners of a square, moving art the grab is nowhere near (reshape_moves_the_grabbed_point_and_its_neighbourhood).
2026-10-18 20:12:47 +07:00
NetterSilverandClaude Haiku 5.5 f2601137ab Resolve the "ft" unit suffix to Feet in Unit::named
Found while testing crates/doc/src/lib.rs:274 (Unit::named)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 20:12:47 +07:00
bflatastic 9938a31c9b Pencil: a stroke that starts and ends on a selected path redraws the section between (Edit Selected Paths, #2850)
path.freehand takes redraw: {id, within}: the stroke's ends are placed on the path, anchors added there, and the section between them replaced by the fitted stroke, the shorter way round a closed subpath; a stroke that doesn't end on the path still draws a new one. The Pencil and Paintbrush send it for a stroke that starts on a selected path's outline away from its ends, within Edit Selected Paths' distance.
2026-10-18 20:12:47 +07:00
bflatastic 5f37fccdce New Document: Recent and Saved cards keep the orientation they were made in, so one A3 card is chosen, not two (#2860)
The cards turned to the chosen orientation in every category, so a portrait and a landscape A3 in Recent looked the same and both read as chosen. Only the built-in presets' cards turn now.
2026-10-18 20:12:47 +07:00
nrq6993-a11yandCursor e8e88c14f2 Fix #2723: Reshape falloff uses arc length along the path
Co-authored-by: Cursor <[email protected]>
2026-10-18 20:12:47 +07:00
BFlat 2cdb0d2d53 Merge pull request #2893 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-18 19:36:12 +07:00
Dylan M. Taylor fe16f852db docs: clarify readiness audience and tracked features baseline 2026-10-18 08:28:33 -04:00
BFlat 43bb588e5e Merge pull request #2897 from storytold/fix/2879-baselines
Canvas: selected point type's baseline marker runs under each line, centred and right-aligned type too (#2879)
2026-10-18 19:20:48 +07:00
BFlat a57eaa0782 Merge pull request #2896 from storytold/fix/2873-w-weights
SVG export: a Japanese face's numbered weight (W0–W9) is its font-weight, W6 600 (#2873)
2026-10-18 19:20:39 +07:00
BFlat 007df2ee24 Merge pull request #2877 from AJMakesStuff/main
Add interactive Mirror Repeat axis controls
2026-10-18 19:20:05 +07:00
BFlat 0955e6845c Merge pull request #2884 from nrq6993-a11y/stream/vc-issue-2576-font-poll
Fix #2576: poll for fonts that font managers activate while the app is in front
2026-10-18 19:19:49 +07:00
BFlat 4bafe834a6 Merge pull request #2887 from ham9zah/fix/arabic-alternate-glyph-highlight
Part of #394: required shaping is not an alternate glyph
2026-10-18 19:19:33 +07:00
BFlat 87603e4c35 Merge pull request #2876 from ham9zah/fix/selection-clip-group-outlines
G1: test that a group of clipped images shows only its masks and their bounds
2026-10-18 19:19:19 +07:00
BFlat e60882a3a1 Merge pull request #2870 from lewandowskit/pl-terminologia
i18n(pl): Artboard is "obszar kompozycji", Guides "linie pomocnicze"
2026-10-18 19:19:05 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 25a987a94f Release: embed the current craft-fonts (adds Noto Sans Georgian, craft-fonts#27)
The release and FreeBSD workflows pinned craft-fonts 3d9ab3a1, two commits before #27,
so release builds had no Georgian face for the ka interface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 16:09:28 +04:00
bflatastic bb14031aa3 Canvas: selected point type's baseline marker runs under each line, centred and right-aligned type too (#2879)
The marker ran from the anchor to the right edge of the bounds, so centred type showed half a line and right-aligned type none, and rotated type a level line. It now draws each line's baseline in text space (TextObject::baselines, the lines a click selects the type on), turned with the type.
2026-10-18 19:02:52 +07:00
bflatastic eec304dde1 SVG export: a Japanese face's numbered weight (W0–W9) is its font-weight, W6 600 (#2873)
style_weight reads W0–W9 style names (Hiragino, Morisawa…) as their weight classes before the English words, so SVG and CSS export write font-weight 600 for W6, and nearest-style matching weighs them too.
2026-10-18 19:02:52 +07:00
bflatastic b934451b6d Docs: the Mirror Repeat axis rows name #2877 and its passing CI, and keep #2564 in the status lines 2026-10-18 19:02:44 +07:00
bflatastic 5906f53ca5 Fonts: the timed look for activated fonts doesn't wake an idle app; the next frame reads it (#2884) 2026-10-18 19:02:44 +07:00
AJ Richardson 411f5f7680 prioritize bounding-box handles over mirror axis 2026-10-18 19:02:44 +07:00
AJ Richardson 63cebd392a Add interactive Mirror Repeat axis controls 2026-10-18 19:02:44 +07:00
bflatastic 3041b6a672 Tests: the font-service checks of #579 and #2576 are one test, since the platform lister is set once per process (#2884) 2026-10-18 18:56:38 +07:00
Dylan M. Taylor 090659b78b docs: drop references to competing commercial software 2026-10-18 07:52:58 -04:00
nrq6993-a11yandCursor 640c87b537 Fix #2576: poll for fonts font managers activate while the app is in front
Font services (NexusFont, Adobe Fonts) register faces with the OS without
touching font-folder mtimes; only re-asking the platform lister sees them.
Check every two seconds as well as on window focus, and test that path churn
is detected on the system font scan.

Co-authored-by: Cursor <[email protected]>
2026-10-18 18:46:03 +07:00
ham9zah bf2379f401 Part of #394: required shaping is not an alternate glyph
Highlight Alternate Glyphs (8689863f) flagged any glyph that isn't its
character's cmap glyph, so the joining forms Arabic and other complex
scripts always take (init, medi, fina, isol, rlig, ccmp and the Indic
required set) covered ordinary text in pink. A glyph is now an
alternate only when required shaping alone doesn't give it: the text is
laid out again with every optional feature off (liga, clig, calt and
the features its characters turn on) and each candidate glyph compared
by cluster. The pink boxes are measured once per text geometry, only
when the preference shows them.
2026-10-18 18:46:02 +07:00
ham9zah 561db27fef G1: test that a group of clipped images shows only its masks and their bounds
A group of four clip groups, each a placed image larger than its
rounded-square mask: one Selection click selects the group, which
shows the four masks' outlines and no image rectangle, with a bounding
box and bounds equal to the union of the masks. The behaviour landed
in #2650 (part of #2422); this covers the grouped case.
2026-10-18 18:46:01 +07:00
Tomasz LewandowskiandClaude Opus 5.5 cc1a496947 i18n(pl): Artboard is "obszar kompozycji", Guides "linie pomocnicze"
Three new Polish rows broke the catalog's own vocabulary:

- "By Artboard or Item" and "Artboard or Item Name" (Export for Screens)
  read "obszar roboczy", which this catalog uses for Workspace; Artboard is
  "obszar kompozycji" everywhere else (72 rows).
- "Enable Guides for 9-Slice Scaling" read "prowadnice"; Guides are
  "linie pomocnicze" in the other 29 rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-18 18:46:00 +07:00
BFlat 48ef70e873 Merge pull request #2045 from devius/ka-language
i18n: Georgian (ka) interface language
2026-10-18 18:41:19 +07:00
BFlat b58100c2f2 Merge pull request #2859 from ham9zah/fix/ai-rtl-paragraph-alignment
G9: .ai right-to-left paragraphs keep their alignment
2026-10-18 18:41:03 +07:00
bflatastic 37b90eb050 i18n: Georgian rows for the strings added since #2045 last merged main
Cancel Export, Clear History, Justification…, the .ait template and editing-data notes, the variable-library note, and the style undo templates.
2026-10-18 18:29:55 +07:00
Davit Matchakhelidze 7cf8dd4764 i18n: Georgian (ka) interface language
Squashed from #2045 (merge commits from main dropped).
2026-10-18 18:09:14 +07:00
ham9zah 4547439176 G9: .ai right-to-left paragraphs keep their alignment
A right-to-left paragraph in an Illustrator editing copy (key 33 of its
paragraph attributes) stores its alignment relative to its direction:
"left" lays the line out ending at the anchor and "right" starting there.
The importer read the alignment alone, so RTL point type opened shifted by
its own width (Arabic headings past the artboard edge, subtitles starting
where their title ends, a label over a QR code).

Checked locally against ~1,440 single-line point-type stories in a user's
own files (never committed): RTL + left ended at the anchor in 332 of 336,
RTL + right started there in 51 of 51; LTR and centred type unchanged.
Justified alignments keep their current mapping (too few samples).
2026-10-18 18:08:58 +07:00
BFlat 797e899c56 Merge pull request #2865 from storytold/fix/batch10-followups
Batch follow-ups: #2842 note translated, test fixes for #2824, #2771 and #2829, ROADMAP
2026-10-18 18:08:29 +07:00
bflatastic 6516e8611c Tests: freeform points recolored from CMYK stops mix in CMYK, as #2829 makes them (#2812) 2026-10-18 13:26:54 +07:00
bflatastic e342c2843a Tests: the bidi caret test's glyphs carry Glyph Scaling's scale of 1 (#2771) 2026-10-18 13:26:54 +07:00
bflatastic 52b6543c2e Docs: the ROADMAP's 2026-10-18 row includes the second batch 2026-10-18 13:26:54 +07:00
bflatastic 1e3ab306d4 Tests: the dropdown keyboard test clears the texture updates it doesn't apply (#2824) 2026-10-18 13:26:54 +07:00
bflatastic 1b4c421bc4 .ai import: the variable-library note reads its counts after a colon and is translated in every complete language (#2842) 2026-10-18 13:26:54 +07:00
BFlat 73f40e3fd3 Merge pull request #2840 from KaboomFox/plugin-dialog-scrolls
Dialogs: a plug-in's parameters scroll when they don't fit the window
2026-10-18 13:26:39 +07:00
BFlat 049739b85e Merge pull request #2824 from MarekSpodBiedry/issue-2789
Close a dropdown list after an option is picked with the keyboard (#2789)
2026-10-18 13:26:24 +07:00
BFlat dcad0878c5 Merge pull request #2802 from fabian-co/perf/cmyk-gradient-mesh
Render: gradient meshes in CMYK documents draw several times faster
2026-10-18 13:26:12 +07:00
BFlat 08c21446b4 Merge pull request #2564 from fabian-co/feat/export-progress-dialog
Export progress dialog with Cancel, on the shared cancel token
2026-10-18 13:25:58 +07:00
BFlat d4a68dd73f Merge pull request #2771 from VL420/feat/justification
G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
2026-10-18 13:25:41 +07:00
BFlat 45ca05e7b2 Merge pull request #2852 from dotCooCoo/chore/parity-doc-duplicate-lines
Docs: target-app-parity.md lists seven Evidence items once each
2026-10-18 13:25:26 +07:00
BFlat 14d1667a7e Merge pull request #2851 from dotCooCoo/fix/live-commands-refuse-text-numbers
Blends, meshes and envelopes: a number given as text is refused
2026-10-18 13:25:16 +07:00
BFlat 5a2510576f Merge pull request #2846 from dotCooCoo/fix/slice-drags-snap
Slices: resizing a slice snaps as drawing one does
2026-10-18 13:25:00 +07:00
BFlat 47b5e886f8 Merge pull request #2845 from dotCooCoo/fix/shift-move-snaps-along-its-line
Selection: a Shift-constrained move that snaps stays on its line
2026-10-18 13:24:46 +07:00
BFlat 650e938b68 Merge pull request #2844 from dotCooCoo/fix/path-bounds-with-huge-control-points
Paths: a curve with a handle near f64::MAX is measured by its control points
2026-10-18 13:24:32 +07:00
BFlat 36d751b72f Merge pull request #2843 from dotCooCoo/feat/history-clear-history
History: the panel menu clears the history
2026-10-18 13:24:18 +07:00
BFlat c40718fafa Merge pull request #2842 from dotCooCoo/feat/ai-import-warns-unread-variables
.ai import: opening a file with a variable library warns that its variables weren't read
2026-10-18 13:24:02 +07:00
BFlat d9d1a1aba3 Merge pull request #2839 from dotCooCoo/fix/export-for-screens-example-name
Export for Screens: the dialog's example names the file the export writes
2026-10-18 13:23:47 +07:00
BFlat 2c4f059444 Merge pull request #2838 from dotCooCoo/fix/pdf-export-keeps-created-date
PDF export: PDF and .ai files keep the document's creation date
2026-10-18 13:23:33 +07:00
BFlat dba668240c Merge pull request #2829 from dotCooCoo/fix/freeform-gradients-keep-cmyk
Color: freeform gradients keep CMYK inks when expanded, meshed or edited
2026-10-18 13:23:18 +07:00
BFlat a0657c9a22 Merge pull request #2836 from dotCooCoo/fix/drag-budget-test-threads
Tests: the drag-budget test sets its thread count, and no render test changes the default
2026-10-18 13:23:04 +07:00
BFlat 5f20ef194b Merge pull request #2783 from dotCooCoo/fix/linux-save-picks-chosen-type
Save As: on Linux the file type picked in the save dialog decides the format
2026-10-18 13:22:50 +07:00
BFlat eb8eaa7aa0 Merge pull request #2825 from dotCooCoo/fix/pattern-rename-refuses-swatch-names
Patterns: Pattern Options refuses a name another swatch has
2026-10-18 13:22:35 +07:00
BFlat 1d6daaefcc Merge pull request #2819 from dotCooCoo/fix/template-refuses-legacy-ai
Save as Template: aiVersion 7 is an error for a .ait, and the dialog shows no Version
2026-10-18 13:22:20 +07:00
BFlat 78b7ffe1fb Merge pull request #2813 from dotCooCoo/fix/export-ait-names-save-as-template
Export: a .ait export names file.saveAsTemplate, and the CLI's convert writes the template
2026-10-18 13:22:06 +07:00
BFlat 31df7aee9a Merge pull request #2812 from dotCooCoo/fix/cmyk-document-converts-rgb-everywhere
Color: type, gradient, mesh and swatch commands store RGB input as color-managed CMYK in a CMYK document
2026-10-18 13:21:51 +07:00
BFlat 2909972140 Merge pull request #2803 from dotCooCoo/fix/history-template-step-names-translated
i18n: the History panel translates the step names that contain a style's name
2026-10-18 13:21:36 +07:00
BFlat cfe77f1706 Merge pull request #2805 from dotCooCoo/fix/marquee-intertwine-by-members
Selection: a marquee selects an Intertwine group or a repeat only where it meets their art
2026-10-18 13:21:22 +07:00
BFlat 69dad83148 Merge pull request #2782 from dotCooCoo/fix/tools-skip-shaper-sources
Shaper: the Lasso, Smart Guides and the anchor tools skip a Shaper Group's original shapes
2026-10-18 13:21:08 +07:00
BFlat 5646fa7253 Merge pull request #2796 from dotCooCoo/fix/appearance-inline-3d-and-channel-dropdowns
Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
2026-10-18 13:20:53 +07:00
BFlat 545e935e0c Merge pull request #2794 from dotCooCoo/fix/color-sliders-report-values
Accessibility: color and channel sliders report their values to screen readers
2026-10-18 13:20:39 +07:00
BFlat 46a1d9f175 Merge pull request #2785 from dotCooCoo/fix/panel-fields-shift-enter
Fields: Shift+Enter applies a pattern, layer, artboard or asset name and keeps the field active
2026-10-18 13:20:24 +07:00
BFlat f794069fbd Merge pull request #2797 from dotCooCoo/fix/whole-selected-group-anchors-drawn-selected
Direct Selection: a group selected whole shows its anchors as selected
2026-10-18 13:20:10 +07:00
BFlat c2bd5b4a52 Merge pull request #2795 from dotCooCoo/fix/caret-steps-past-zero-width-clusters
Type: Left and Right reach both sides of a character drawn with no width
2026-10-18 13:19:55 +07:00
BFlat 2623e95a13 Merge pull request #2809 from dotCooCoo/fix/repeat-tab-from-left-indent
Tabs: Repeat Tab measures the first stop from the left indent
2026-10-18 13:19:40 +07:00
BFlat ced933af47 Merge pull request #2815 from dotCooCoo/fix/rtf-rtl-justify
Place: justified RTF paragraphs marked right to left end their last line on the right
2026-10-18 13:19:24 +07:00
BFlat c29701f65b Merge pull request #2806 from dotCooCoo/fix/place-bounding-skips-hidden-art
Place: Bounding Box measures only the art that shows
2026-10-18 13:19:13 +07:00
BFlat 44e7be8b04 Merge pull request #2781 from dotCooCoo/fix/grid-images-count-decode-failures
Render: raster exports warn about an undecodable image drawn from a kept Rasterize image
2026-10-18 13:18:57 +07:00
BFlat 2c93a290e8 Merge pull request #2790 from dotCooCoo/fix/pdf-export-skips-undecodable-images
PDF export: an image that can't be decoded is left out with a note instead of failing the export
2026-10-18 13:18:43 +07:00
BFlat 646a5cc9a3 Merge pull request #2801 from dotCooCoo/fix/issue-2692
Legacy .ai export: images are written in the form the version 7 specification shows
2026-10-18 13:18:28 +07:00
BFlat 20c8296b5a Merge pull request #2807 from dotCooCoo/fix/issue-2687
Legacy .ai export: radial gradients are written with their highlight (Bh)
2026-10-18 13:18:14 +07:00
BFlat eb5bf7b828 Merge pull request #2817 from dotCooCoo/fix/legacy-ai-warns-undecodable-image
Legacy .ai export: warns when an image in art it flattens or expands can't be decoded
2026-10-18 13:18:03 +07:00
BFlat 36b85472d2 Merge pull request #2808 from dotCooCoo/fix/ai-import-covered-type-spells-text
.ai import: page type over a text object is left out only when that object's text has its letters
2026-10-18 13:17:47 +07:00
BFlat e69a08c1a6 Merge pull request #2799 from dotCooCoo/fix/issue-2034
EPS and .ai import: the note for editing data of another page size gives both sizes and the scale
2026-10-18 13:17:31 +07:00
BFlat 602b3b34bc Merge pull request #2800 from dotCooCoo/fix/ai-undecodable-editing-data-note
.ai import: a file whose editing data can't be decompressed says why its layers weren't read
2026-10-18 13:17:16 +07:00
BFlat 9361a3b3f1 Merge pull request #2810 from dotCooCoo/fix/pdf-layer-names-pdfdocencoding
PDF import: PDF and .ai layer names without a byte order mark are read as PDFDocEncoding
2026-10-18 13:17:02 +07:00
BFlat dfb1a86a23 Merge pull request #2816 from dotCooCoo/fix/xmp-nesting-limit
PDF import: an XMP packet nested more than 256 deep is skipped instead of overflowing the stack
2026-10-18 13:16:47 +07:00
BFlat 3f673c35eb Merge pull request #2776 from dotCooCoo/fix/mcp-canned-replies-respect-queue-limit
MCP: parse errors and overlong lines wait for room in the 64-line queue
2026-10-18 13:16:30 +07:00
BFlat 2d2698e5fb Merge pull request #2837 from dotCooCoo/fix/svg-import-paths-on-windows
SVG import: hrefs that need no folder stay as written, and images in a non-absolute folder are read
2026-10-18 13:16:15 +07:00
Louise FoxandClaude Opus 5.5 0842941f8d Dialogs: a plug-in's parameters scroll when they don't fit the window
A plug-in's dialog (Effect › Plug-ins, Object › Plug-ins) lays out each
parameter of its manifest as a row, and a manifest may declare up to 64.
With many of them the dialog ran off the top and the bottom of the
window, its heading and its OK and Cancel buttons off screen. A plug-in
of 64 number parameters got a dialog 2,329 px tall, its top at y = -755
in a 900 px window.

modal::scrolled_rows() puts rows in a vertical scroll area as tall as
the screen less the rest of the modal window and its edge gaps.
modal::show_at() measures that rest once it has drawn the window, for
the next frame, and asks for that frame at once if the rest changed.
The dialog then fits without waiting for input. schema_fields() puts the
parameter grid in it, for filters and effects alike. A dialog of a few
parameters is as tall as before.

a_plug_in_dialog_of_many_settings_fits_the_window opens an effect's and
a filter's dialog of 64 parameters in a 1280 × 900 window, and one of 4.
Before the change it failed with "effect: the dialog is at
[[457.0 -755.0] - [823.0 1574.0]] in the [1280.0 900.0] window".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-18 12:41:25 +07:00
MarekSpodBiedry df1bf3fe32 Close a dropdown list after an option is picked with the keyboard (#2789) 2026-10-18 12:41:25 +07:00
Louise FoxandClaude Opus 5.5 dbb6b470b3 Menus: leave installed plug-ins' names out of the strings to translate
menu_strings() lists the strings a complete language catalog has to
cover, and it leaves out names that are user data: fonts, recent files,
saved views and libraries. An installed plug-in's name stayed in.
Plug-ins are installed for the whole process, and a test that installs
one before complete_languages_translate_every_menu_string runs made that
test fail with the plug-in's name as an untranslated menu string.

lists_plugin() tells an Object › Plug-ins filter or an Effect ›
Plug-ins effect by its command and params. Those are part of the menu
item itself and stay right while another test installs or removes
plug-ins. menu_strings() uses it, and so does
menu_catalogs_translate_every_menu_label in place of its own copy of the
rule.

plugins_are_listed_in_the_object_and_effect_menus checks it. Before the
change it failed with "Desaturate… is a menu string to translate".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-18 12:41:25 +07:00
Fabian GrajalesandClaude Opus 5.5 9735518c84 Render: a gradient mesh is rasterized into at most 4 MP (16 MB) at once
The image a mesh's quads are filled into is clipped to the frame; at 16 MP a
mesh filling a large canvas allocated up to 64 MB per frame. A larger mesh is
drawn quad by quad, backed by its patches' outlines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
2026-10-18 12:41:21 +07:00
Fabian GrajalesandClaude Opus 5.5 cf5caa43d5 Render: gradient meshes laid edge to edge meet without seams
Drawing each mesh as one image through its antialiased outline (dadfa06b)
left a thin line of the background wherever two meshes met: the two
partly covered edge pixels don't add up to full coverage. A page made of
thousands of small meshes (a PDF from a gradient-mesh illustration) showed
a grid of lines on screen and in raster exports.

- The outline is flattened and moved out by half a device pixel
  (grown_outline), as the quads drawn one by one are, and filled once: no
  doubled edge, so translucent meshes are grown too.
- The edge colours spread two pixels out of the quads (the grown edge
  samples up to about a pixel and a half out), and the buffer has two
  pixels of margin.

Tests: an_outline_grows_by_half_a_pixel_both_ways_round; the edge-spread
test checks the second pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:41:21 +07:00
Fabian GrajalesandClaude Opus 5.5 ed69938e0e Render: gradient meshes in CMYK documents draw several times faster
A PDF of thousands of gradient meshes in a CMYK document with transparency
took minutes to export (186 s at 72 ppi for an 18,577-mesh page). Each mesh
was cut into quads coloured in RGB, and drawing a CMYK document by ink planes
separated every quad's colour back to CMYK through the profile, twice: the
4,096-colour separation cache, flooded by the meshes' interpolated colours,
cleared over and over.

- doc: GradientMesh::color_at mixes corners that are all CMYK (or all grey)
  in that space, as a PDF shading mixes in its own: exact inks, nothing to
  separate.
- render: the separation cache holds 65,536 colours.
- render: a mesh's quads are filled into one image by scanline (every pixel
  to the quad its centre is in: no seams) and drawn through the mesh's
  outline, instead of one path per quad; meshes over 16 MP on screen are
  drawn quad by quad as before, backed by each opaque patch's outline (#2456).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:41:21 +07:00
Fabian GrajalesandClaude Opus 5.5 ec410552cf Export: a progress dialog with Cancel for background exports
While an export runs in the background (Background Export), a dialog shows
what is being exported and how far it has got: artboard by artboard for a
raster export of several artboards, busy otherwise. Cancel (also Esc) stops
it and writes no file; Continue in Background leaves it to the status bar.
It opens after 0.4 s and never over another dialog.

Built on the shared vectorcraft_doc::cancel (#2546): fileio::progress::Progress
wraps its CancelToken and a progress sink, so a cancelled export stops
drawing and keeps no half-drawn pattern tile or effect raster.

- ui: background jobs carry their Progress; export_progress draws the
  dialog; UI commands file.cancelExport and file.exportInBackground.
- i18n: the strings in every language with complete menus, Danish, Dutch
  and Indonesian included; "{n} more exports waiting" is a plural (trn).
- docs: ui-parity, gaps (G12) and ROADMAP rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
2026-10-18 12:40:20 +07:00
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:40:14 +07:00
dotCooCoo 5851d8b1ad Docs: target-app-parity.md lists seven Evidence items once each
The Evidence list in docs/target-app-parity.md had two or three
versions of several items.

Seven items now have one line each: the Actions panel, Editing type,
Releasing ruler guides, the Layers panel, choosing the Artboard tool,
Image Trace Flat Logo mode and Create Strokes. Each kept line holds
every number, path and pull request reference of the lines it
replaces. The Layers panel line gains the #2260 note on layers dropped
under the last row. The Create Strokes line keeps the newer of its two
statements, which no longer lists the skeleton graph among the
remaining work.

Open pull requests edit the lines of seven other items, or the lines
beside them (Preferences that act, Initial live Revolve, Live effects
with previewing dialogs, Type, Masks and clipping, SVG/SVGZ, and EPS
and DXF), and those items are unchanged.
2026-10-18 12:40:13 +07:00
dotCooCoo d8b0ce1013 Blends, meshes and envelopes: a number given as text is refused
The blend, envelope and gradient mesh commands read their optional
numbers with f64_or or as_f64, which fall back to a default for a value
that is not a JSON number. object.blend.make {"steps": "8"} made a
Smooth Color blend and reported success. Blend Options did the same
with steps, distance and value. Make with Warp, Reset with Warp and
Envelope Options did the same with bend, h and v (and their long
names). Make with Mesh, Reset with Mesh and Create Gradient Mesh did
the same with rows, cols and columns. Envelope Options did the same
with fidelity, Create Gradient Mesh did the same with highlight, and
Set Mesh Point Color did the same with opacity.

These commands now fail with "`<key>` must be a number, not <value>"
for a value that is not a number or null, as the transform commands
do, and change nothing. Envelope Options refuses the warp keys whether
or not a warp envelope is selected. Create Gradient Mesh refuses an
`at` that is not exactly two numbers with "`at` must be [x, y]".
Before, it took the first two entries of a longer array, and for any
other value it made a mesh with no lines through the point.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:40:12 +07:00
dotCooCoo 282ed8955b Slices: resizing a slice snaps as drawing one does
Dragging a user slice's edge or corner handle with the Slice Selection
tool never snapped, while drawing a slice with the Slice tool snaps to
the grid, to whole pixels, to the art and to the ruler guides.

The handle drag now gathers its targets when the handle is pressed and
snaps what the handle moves through PointSnap::snap_scale, as the
Selection tool's bounding-box handles do: to the nearest gridline with
Snap to Grid, to whole pixels with Snap to Pixel, to the art and the
artboards with Smart Guides, and with Smart Guides off to the art's
anchors and the ruler guides with Snap to Point. Then a shown, unlocked
ruler guide within 5 px moves the side or corner onto it, as the Slice
tool moves a drawn slice's corner onto a guide. Shift still keeps the
proportions, and Alt still resizes about the center.

Docs: docs/target-app-parity.md.
2026-10-18 12:40:11 +07:00
dotCooCoo 034ef916ad Selection: a Shift-constrained move that snaps stays on its line
A move that Shift kept level, upright or on a diagonal left that line
when it snapped. With View › Snap to Grid on, a level Shift-drag also
moved the selection up or down onto a gridline, and Snap to Pixel,
Smart Guides and Snap to Point moved it off the line in the same way.
Each axis snapped on its own after Shift had constrained the move.

With the Selection tool, and with Group Selection and Direct Selection
moving whole objects, a Shift-constrained move now slides along its
line until it snaps. With Snap to Grid or Snap to Pixel, the move
slides along the line by the shorter of the two distances that put it
on a gridline or a whole pixel on either axis. The bounds line up with
the other objects, or space evenly with them, only on the axes the line
runs along, and the grabbed point stops where the line passes an anchor
or crosses a ruler guide. A level or upright move snaps along its own
axis only. Moves without Shift snap as before.

Docs: docs/target-app-parity.md.
2026-10-18 12:40:10 +07:00
dotCooCoo 138e9ca466 Paths: a curve with a handle near f64::MAX is measured by its control points
PathData::bounds measured each segment by its start, end and extrema,
as kurbo's bounding_box does. For a curve with a handle at about 1e308
in size, or an infinite one, kurbo's extrema computation overflows and
drops the extreme. The box then held only the anchors. The canvas check
after an edit reads that box for the selected objects, so path.create
kept a curve with handles at 1e308.

The new segment_bounds measures a segment by its control points when
one of them has an x or y that is infinite or larger in size than
f64::MAX / 16, and by its extrema otherwise, bit for bit as before.
curve_bounds joins those boxes as kurbo does, and PathData::bounds uses
it, so such a curve's box holds the whole curve and the canvas check
refuses an edit that leaves one selected.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:39:09 +07:00
dotCooCoo 1caa957531 History: the panel menu clears the history
The History panel menu had no Clear History, and VectorCraft had no
command that removes the undo and redo steps. In the reference app,
Clear History in the History panel menu removes the states after the
selected state, and it removes the states before it while their
changes remain in the document.

edit.clearHistory (Clear History) removes every step to undo and to
redo in the active document and returns how many it removed. The
document and the selection stay as they are, and clearing is not an
undo step. Typing or a drag still in progress becomes a step first, as
Undo makes it one, so its change stays in the document. The command is
disabled when there is no step to clear, and it waits while an input
method composes, as Undo does.

The History panel menu has Clear History after Step Forward. It runs
the command and is disabled when there is no step to clear.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:39:07 +07:00
dotCooCoo 4803573940 .ai import: opening a file with a variable library warns that its variables weren't read
A .ai can keep its variables and data sets in a variable library in
its editing data. VectorCraft doesn't read that library, and such a
file opened with an empty Variables panel and no warning about it.

The reader now counts the library's variables and data sets in the
editing data's /Document dictionary, and opening the file adds a
warning: "the file's variables and data sets weren't read (2
variables, 3 data sets): the Variables panel doesn't list them". The
same warning appears when the layers can't be read and the file opens
from its PDF part. A PDF that opens from the editing data it carries
gets the warning too. A library with no variables and no data sets
gives no warning.

Docs: docs/target-app-parity.md.
2026-10-18 12:38:06 +07:00
dotCooCoo 3c0c112fbd Export for Screens: the dialog's example names the file the export writes
The Export for Screens dialog built its Example line from its fields as
typed, so it often showed a name the export does not write. It used the
artboard's name with its spaces (Artboard 1.png where the export writes
Artboard-1.png) and the first artboard whatever the range, kept a
prefix, a suffix or a device name such as CON as typed, used the scale
text as the row folder instead of the row's own folder or its size,
kept a vector row's @2x suffix, left out the folder that Create
Sub-folders adds, and read a counter set as text as 1.

The line now comes from the export's own naming. The naming part of
document.exportForScreens moves into a Naming type that the export
uses for every file, and fileio::screen_first_file returns the path of
the first file the export writes for the given params, without encoding
or writing anything. The dialog passes it the params it exports with,
without the destination, so the line shows before a folder is chosen
and is left out while the settings choose nothing to export. The Start
at field shows the counter the export uses when it was set as text.

Docs: docs/target-app-parity.md.
2026-10-18 12:37:29 +07:00
dotCooCoo aa9cf83fd2 PDF export: PDF and .ai files keep the document's creation date
The engine built the PDF options without a creation date, so the
writer dated every PDF and PDF-compatible .ai file with the time it
was written. A document created on 2020-01-01 (File › File Info
shows the date) exported as a PDF with today's date in /CreationDate
and xmp:CreateDate. SVG export with the metadata option already
writes the document's own date.

The options now carry the document's File Info created date and the
time of writing as the modification date. A document without a
creation date is dated with the time of writing, as before, so PDF/X
files still get their required date. The PDF writer writes one date
for both, so the export then sets /ModDate, xmp:ModifyDate and the
PDF/A history dates to the time of writing; /CreationDate and
xmp:CreateDate keep the document's date. The page information mark
and the embedded editing data's modification date keep the time of
writing.

Docs: docs/mcp.md (File Info), docs/target-app-parity.md.
2026-10-18 12:36:27 +07:00
dotCooCoo 24d4777d1a Color: freeform gradients keep CMYK inks when expanded, meshed or edited
Object > Expand and Object > Create Gradient Mesh mixed a freeform
gradient's points in display RGB and turned the result back into the
points' model with the profile-free formula. CMYK inks changed: points
of C50 M20 Y0 K10 gave mesh points of C45 M24 Y0 K22. RGB points were
stored as display sRGB, so with a working RGB space other than sRGB
the mesh showed other colors. Points colored along CMYK stops, a point
paint.freeform.splitLine added between CMYK points and a point
paint.freeform.addPoint added without a color came out as RGB.

Points that share a color model now mix in it: the C, M, Y and K
values mix as inks, and Gray, RGB and Lab components mix as they are.
Points in different models still mix in display RGB. Object > Expand
and Object > Create Gradient Mesh store the result in the working RGB
space, and in a CMYK document they store it as CMYK through the color
settings, as paint.setFill stores an RGB color. Freeform::recolor and
split_line mix with the new Color::lerp_keep_model, which
vectorcraft_doc::blend::lerp_color now calls.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:36:25 +07:00
dotCooCoo 739b0b7335 Tests: the drag-budget test sets its thread count, and no render test changes the default
The FreeBSD job fails
fx::tests::a_drag_renders_raster_effects_within_the_drag_budget at
fx.rs:2822 with 1 previewed instead of 2.

pixels_do_not_depend_on_the_thread_count called set_default_threads
with 0, 1, 4 and 8. The override is process-wide, so a Renderer::new()
in a test running at the same time took that count. With 0 threads a
drag renders the drop shadow at full resolution, as
RenderOptions::dragging documents, so stats.previewed counts only the
filtered content (1 instead of 2).

The thread-count test now sets each renderer's threads. The drag test
gives its renderers 3 threads, so it also passes where default_threads()
is 0 (one CPU, or VECTORCRAFT_RENDER_THREADS=0).
2026-10-18 12:36:24 +07:00
dotCooCoo 468d7b21e9 Save As: on Linux the file type picked in the save dialog decides the format
On Linux the save dialog is the XDG desktop portal's, shown through
rfd. rfd sends no `current_filter` and reads only the file name from
the portal's answer, and the GTK file chooser keeps the typed name
when another file type is picked. With "PDF-compatible .ai" picked,
`poster` typed for a new document was saved as a native
`poster.vectorcraft`, and so was the suggested
`Untitled-1.vectorcraft` left as it was.

When a save offers two or more file types (Save As, Save a Copy, Save
as Template and the first Save of a new document), the app now calls
the portal's SaveFile itself over D-Bus. The dialog starts on the
suggested file's type, and its answer names the type picked. A name
typed without an extension, or the suggested name left as it was,
gets that type's extension. A name typed with another save format's
extension, other than the suggested file's (for Save as Template, a
template format's), is saved in that format, and any other extension
gets the picked type's added. The dialog keeps the window as its
parent on X11 and Wayland, and rfd shows the dialog when the portal
can't. The same code runs on the BSDs; Windows, macOS and the web
build keep their dialogs.

Docs: docs/target-app-parity.md.
2026-10-18 12:36:23 +07:00
dotCooCoo db3092ae2e Patterns: Pattern Options refuses a name another swatch has
pattern.options refused a new name that another pattern had, but not
one that a color swatch, a gradient swatch, a second swatch of the
pattern or a color group had. The rename then gave the pattern's swatch
that name, so two swatches, or a swatch and a color group, had one
name. The rename also gave the new name to every swatch that had the
old one, a color swatch included, and art linked to that color swatch
kept a link to a name no swatch had. Make and Save a Copy numbered a
pattern's or a swatch's name, but not a color group's.

pattern.options now refuses a name that a swatch or a color group has,
with "a swatch or color group named `<name>` exists". It takes a name
that only the pattern's own swatches have, such as a name Swatch
Options gave the pattern's swatch, when none of them still has the old
name. A rename gives the new name only to the pattern's swatches that
had the old one. Make and Save a Copy number a color group's name as
they number a swatch's. The new message has a row in the 17 catalogs
that translate every message (COMPLETE_MESSAGES) and in bg, da, id and
nl.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:36:22 +07:00
dotCooCoo 0cf12a1611 Save as Template: aiVersion 7 is an error for a .ait, and the dialog shows no Version
file.saveAsTemplate with format ait and aiVersion: "7" wrote the
legacy PostScript .ai of version 7 under the .ait name: for a
one-artboard test document, a 7,556-byte file starting %!PS-Adobe-3.0.
The web's Save as Template dialog showed the .ai Version menu for
.ait, whose Legacy (version 7) choice set aiVersion 7.

aiVersion 7 for a .ait is now an error, "aiVersion 7 writes legacy .ai
files; a .ait template is always PDF-compatible", and nothing is
written. The 17 catalogs that translate every message have a row for
it. The dialog shows no Version row for .ait, and aiVersion "pdf"
still writes the PDF-compatible template.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/file-format-parity.md
(two stale rows for .ai and .ait removed).
2026-10-18 12:35:18 +07:00
dotCooCoo d23030ce2b Export: a .ait export names file.saveAsTemplate, and the CLI's convert writes the template
document.export, document.serialize, document.exportSelection and
Export for Screens rows answered "PDF-compatible .ait template files
can be opened but not written" for a .ait, although
file.saveAsTemplate writes them. The CLI's convert and run --export
call document.export, so vectorcraft-cli convert in.pdf out.ait
failed with that error.

The error now reads "PDF-compatible .ait template files are written
by file.saveAsTemplate", with a row in the 17 catalogs that translate
status messages and in bg, da, id and nl. convert and run --export
write a .ait with file.saveAsTemplate, which writes every artboard
with the .ai options. convert passes --replace-lossy and its .ai flags
on, and refuses --artboard and --range for a .ait.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:34:41 +07:00
dotCooCoo 2643f57b1f Color: type, gradient, mesh and swatch commands store RGB input as color-managed CMYK in a CMYK document
In a CMYK document, paint.setFill stores an RGB color as CMYK through
the color settings, but the other commands that take a color stored it
as RGB: text.create, text.createInPath, text.setStyle and
text.setRangeStyle colors, a shapeBuilder.merge hex fill,
paint.editGradient stops, freeform gradient points, gradient mesh
points, the Symbol Stainer's color, and swatch.new {colors},
swatch.newGroup, swatch.editGroup and swatch.edit. The Mesh tool sent
the fill as a hex value, so a CMYK fill came in as RGB.

These commands now store such a color through paint::new_color, as
paint.setFill does, and take keepModel to keep RGB (shapeBuilder.merge
inside its fill object). A color the swatch
or gradient already has keeps its model, so stops sent back with a
moved offset keep their colors (the Gradient panel's ramp and the
Gradient tool send keepModel), and a stop that names a process
swatch takes the swatch's color as it is, in paint.setFill gradients
too. Where the app copies an existing color, it keeps the color's
model: the Color panel's sliders for gradient stops and freeform
points, a swatch put on a stop or a point (the stop popover, Alt-click
in the Swatches panel, a drop on the Gradient panel's ramp), the fill
the Mesh tool gives a new point or the Symbol Stainer applies, and the
colors the Color Guide, Color Themes and Recolor Artwork save as
swatches.

swatch.new {color, mode} converted RGB into CMYK before converting it
into mode, so the New Swatch dialog in RGB mode stored #00ff00 as
R101 G176 B62 in a CMYK document. With mode, the color is now
converted once.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:34:40 +07:00
dotCooCoo ef9e554dd2 i18n: the History panel translates the step names that contain a style's name
The History panel and Edit › Undo and Redo did not translate the steps
that apply or redefine a character or paragraph style, or redefine a
graphic style. The engine built their names with format! ("Apply Body",
"Redefine Body", "Redefine Graphic Style “S”"), and the panel looks a
name up as a whole, so no catalog row matched a name with a style's
name in it. A name cannot be matched against template rows instead: a
character style named Brush gives "Apply Brush", which is also the name
of the brush step.

The engine now names these steps with Session::edit_named, and each
such step keeps its template and the style's name
(HistoryEntry::named) through undo and redo. The History panel and
Edit › Undo and Redo show the template's row with the name in it
(panels::history::entry_name). The name stays as the document holds
it, and the Normal styles' names are shown in the interface language.
Every complete catalog has rows for Apply {name} and Redefine {name},
and Redefine Graphic Style “{name}” had one.
every_undo_label_is_translated also requires a row for each template,
and the only step name it allows to be built with format! is the
Liquify tools' "{} Tool".

Docs: docs/target-app-parity.md.
2026-10-18 12:34:39 +07:00
dotCooCoo 438d27ffbe Selection: a marquee selects an Intertwine group or a repeat only where it meets their art
A marquee measured an Intertwine group and a repeat by their bounding
box. A marquee in the gap between two members of an Intertwine group,
or between two copies of a repeat, selected the object, although a
click there misses and a plain group needs a marquee to meet one of its
members. Direct and Group Selection's marquees also selected the
Intertwine group whole beside the anchor points of its members.

The marquee now touches an Intertwine group by its members, as it
touches a plain group, and it touches a repeat by the copies the repeat
draws, as clicks hit them. Direct and Group Selection's marquees select
the anchor points of an Intertwine group's members, and Group
Selection's E marquee selects the members inside it. Blends, and
envelopes outside Edit Contents, keep their box, as clicks on them do.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:38 +07:00
dotCooCoo 7d56260b47 Shaper: the Lasso, Smart Guides and the anchor tools skip a Shaper Group's original shapes
A Shaper Group keeps its original shapes as its first child, at no
opacity, and clicks do not pick them outside Construction mode. The
Lasso, the Add Anchor Point, Delete Anchor Point, Anchor Point and
Scissors tools still picked their anchor points and segments, also
where the group erased the face, and Smart Guides, Snap to Point and
the transform tools' reference point snapped to them. The Lasso and
the anchor tools also picked art outside the isolated group or object
in isolation mode.

The Lasso and the anchor tools now visit only the art a click can pick:
the isolated object in isolation mode, and a Shaper Group without its
original shapes unless they are isolated (Construction mode). Smart
Guides and Snap to Point leave the original shapes out of their
targets, and Spacing Guides leave out their bounds when a face moves.
The transform tools' reference point snaps to an original shape only
when that shape is selected, or under the pointer in Construction
mode. The Shaper Group test document moves into the shared test
helpers.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-18 12:34:37 +07:00
dotCooCoo 22ce516e2d Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
The Appearance panel's inline effect editor still showed eight choice
parameters as text fields holding the stored value: Revolve's edge,
Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's
Color Model, Background and Anti-aliasing, and the channel of Levels
and Curves. Their dialogs built these lists in their own bodies, so
effect::choices did not hold them. Nothing checked a channel's value:
a typo was stored, and Levels or Curves drew it as RGB.

effect::own_choices now holds these lists, each label as its dialog
passes it to its dropdown or radio buttons. The dialogs read the lists
from it (Rasterize's Anti-aliasing from the AntiAlias list it is built
from), and the inline editor shows each one as a dropdown with the
dialog's translated labels; Rasterize's Color Model reads CMYK or RGB
by the document's mode. effect.apply and effect.setParams refuse a
Levels or Curves channel other than rgb, red, green or blue (r, g, b
and any letter case are read too) with "`channel` must be one of rgb,
red, green, blue", and the document and its undo history stay as they
were.

Docs: docs/mcp.md (effect params), docs/target-app-parity.md.
2026-10-18 12:34:36 +07:00
dotCooCoo 30e54dedcf Accessibility: color and channel sliders report their values to screen readers
The color and channel sliders gave screen readers a name but no value.
Each caller maps its own range onto the thumb's position, so a screen
reader announced the Color panel's G slider, Transform Each's Scale or
the Navigator's Zoom without the setting the field beside it shows.

color_slider_valued and channel_slider_valued take an optional
SliderValue: the number and text of the field beside the slider and
the slider's range. The accessibility node gets the number, the text
and the range, which extends to a number past the slider's end.
color_slider_named and channel_slider_named keep their arguments and
report no value. Every caller outside the tests uses the new variants:
the Color panel, Swatch Options, the Color Picker, the Navigator, the
Character panel's variable font axes and the dialog sliders.
form::slider_rail_named reports the number it edits, and the harmony
wheel's Brightness slider, which has no field, reports its brightness
in percent.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:35 +07:00
dotCooCoo e461b8b818 Fields: Shift+Enter applies a pattern, layer, artboard or asset name and keeps the field active
Shift+Enter (Shift+Return) in the Pattern Options panel's Name field and
in the name fields that a double-click opens in the Layers, Artboards
and Asset Export panels ended the editing, as Enter does. These fields
draw their own text box and do not use the shared field widgets that
#2431 changed.

- Each of them now calls widgets::own_field_shift_enter, built on
  #2431's shift_enter_keeps_focus and after_shift_enter. Shift+Enter
  applies the name as Enter does and the field keeps the focus. In the
  next frame the field shows the name as applied, all selected, so
  typing replaces it.
- Enter, Escape and a click elsewhere work as before.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:34 +07:00
dotCooCoo 496288a023 Direct Selection: a group selected whole shows its anchors as selected
With Direct Selection, the Pen or the anchor tools, a group, compound
path or clip group selected whole drew the anchors of its paths hollow,
the look of unselected anchors, while a path selected whole drew its
anchors filled. After two Group Selection clicks selected a path and
then its group, the group's hollow anchors covered the path's filled
ones.

With these tools, the selection overlay now draws the anchors of the
paths inside a group, compound path or clip group selected whole
filled, as it draws the anchors of a path selected whole. Direction
handles still show only on a path that is selected itself, and Direct
Selection drags only those.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:33 +07:00
dotCooCoo bb7d6b487d Type: Left and Right reach both sides of a character drawn with no width
In type that holds right-to-left characters, Left and Right move the
caret through the visual caret stops (`caret_horizontal`). A cluster
drawn with no advance, such as a zero width non-joiner that the font
shapes as a glyph of its own, puts two stops at one x. A step kept the
stop with the lower byte, and the next step passed over the other one,
so the arrow keys never reached it. In Persian می‌خواهم laid out on
macOS, the caret could not be put between the non-joiner and خ with
the arrow keys.

Stops at one x are now visited one at a time in byte order. Going
right, the caret takes them in increasing order on left-to-right
glyphs and in decreasing order on right-to-left glyphs; going left,
it takes them in the reverse order. The end of a soft-wrapped line,
which the caret shows on the next line, is not one of those stops.
Where no two stops share an x, a step goes where it went before.
2026-10-18 12:34:32 +07:00
dotCooCoo dd5d421a9c Tabs: Repeat Tab measures the first stop from the left indent
Repeat Tab on a paragraph's first tab stop spaced the copies by the
stop's distance from the ruler's 0. The reference app measures that
distance from the paragraph's left indent. With a left indent of 20 pt
and one stop at 50 pt, the copies were 50 pt apart instead of 30.

Repeat Tab now spaces the copies of the first stop by its distance
from the left indent of the paragraph whose stops the Tabs panel
shows: the paragraph the caret is in while the Type tool edits text,
otherwise the first paragraph of the first selected type object. A
stop left of the indent repeats at its distance from the indent: a
stop at 5 pt in a paragraph indented 20 pt repeats every 15 pt. The
reference app's help page does not cover this case, so this follows
VectorCraft's own rule. The copies of a later stop are still spaced by
its distance from the stop before it, and the limits of 20 copies and
2,000 pt stay.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:31 +07:00
dotCooCoo 5df664d1a7 Place: justified RTF paragraphs marked right to left end their last line on the right
File › Place set every justified paragraph (`\qj`) of an RTF file as
Justify with last line aligned left, so a right-to-left paragraph
(`\rtlpar`) ended its last line on the left. Such a paragraph now
places as Justify with last line aligned right, in either order of the
two control words. `\qj` in a left-to-right paragraph still places as
Justify with last line aligned left, and `\ql`, `\qr`, `\qc` and `\qd`
keep their alignments in both directions.
2026-10-18 12:34:30 +07:00
dotCooCoo b27b5b80ea Place: Bounding Box measures only the art that shows
With crop "bounding", file.place measured the placed art by each
object's visual bounds, which include the hidden members of a group.
A group holding a 40 pt square and a hidden 170 x 42 pt rectangle was
measured as 251 x 123 pt: at, rect, the place cursor and
file.place.info all used that box. A group whose only part on the page
or artboard was a hidden member was also placed, and symbol instances
were measured as a 20 pt square.

The art's box now follows the rule exports use
(vectorcraft_render::encode::drawn_bounds, made public): hidden
objects, guides and template layers do not count; the reach of effects
such as a drop shadow now counts; symbol instances are measured by
their art. The same rule picks the objects on the page or artboard.
Hidden objects on the page or artboard are still placed, and stay
hidden. Replace and the width and height that file.place answers also
measure symbol instances by their art.

Docs: docs/mcp.md (Place), docs/target-app-parity.md.
2026-10-18 12:34:29 +07:00
dotCooCoo 2fc47948d5 Render: raster exports warn about an undecodable image drawn from a kept Rasterize image
When a drop shadow or an outer glow follows Effect › Rasterize, the
renderer makes the Rasterize image on the first draw and keeps it for
the shadow and the content, which draw it again. The kept image held no
count of the images that failed to decode while it was made. With a
blur or a filter after the shadow, the content's raster was made from
the kept image and recorded no failure, so a renderer that drew the
object again took the shadow and the content from its caches and
counted nothing. Pattern tiles are drawn by a renderer that keeps these
rasters across exports: a tile drawn again with the same art, for
example after Pattern Options changes the overlap, gave an export
without the "couldn't be decoded" warning although the picture lacked
the image.

The kept Rasterize image now stores how many decodes failed while it
was made, and each draw from it adds that number to the count again, as
kept effect rasters and pattern tiles do.
2026-10-18 12:34:28 +07:00
dotCooCoo df7daff3e4 PDF export: an image that can't be decoded is left out with a note instead of failing the export
When the PDF writer keeps a PNG, GIF or WebP image as it is, it accepts
the image once its header reads, and it reports pixel data that it
can't decode only when it finishes the file. An image whose pixel data
was cut short or damaged therefore failed the whole export with "PDF
writer error: Image(...)", and nothing was written. This happened in
Save as PDF, .ai saves, Save a Copy, Print, Export for Screens and
native files with PDF compatibility, and with On Copy: PDF turned on,
Copy left the PDF off the clipboard.

When the writer fails on an image's pixel data, the export now runs
again with that image left out. Its warnings hold the note that raster
exports give, once: "an image couldn't be decoded and is missing from
the picture". This note replaces the writer's "image '<key>' could not
be decoded and was skipped", which named the image by an internal key.
An export without such an image runs once, as before. A .ai file keeps
the image in its editing data and reopens with it.

Docs: docs/mcp.md (PDF options).
2026-10-18 12:34:27 +07:00
dotCooCoo 030729d6f3 Legacy .ai export: images are written in the form the version 7 specification shows
A version 7 .ai wrote each image's matrix as [a b c d tx ty] and its
samples on comment lines of 64 hex digits. The Adobe Illustrator File
Format Specification version 7.0, which the writer follows, writes the
matrix with a space inside each bracket in the XI syntax (section 8.1)
and in all five examples (section 8.4), and every example data line
holds 60 hex digits. #2692 reports that the reference app leaves out an
image inside a clipping group, and all the art after it, in the files
written from the reporter's two SVGs.

The writer now writes [ a b c d tx ty ] and data lines of 60 hex digits
(the last holds the rest), for every image it writes. VectorCraft's
reader and the file's PostScript prolog read both forms. The result was
not checked in the reference app.
2026-10-18 12:17:05 +07:00
dotCooCoo c0e6d8eb02 Legacy .ai export: radial gradients are written with their highlight (Bh)
The version 7 .ai writer wrote a radial gradient's highlight (Bh) only
when the gradient had a focal point off its center. A centered radial
gradient, such as an SVG radialGradient without fx and fy, was written
Bb, Bg, f, BB with no Bh. Every radial instance in the Adobe Illustrator File
Format Specification version 7.0, which the writer follows, has one,
0 0 0 0 Bh for a centered highlight (sections 3.5.5, 3.5.6 and 9.3).
#2687 reports that the reference app draws such a circle in its outer
color.

The writer now writes Bh in every radial instance, 0 0 0 0 Bh at the
center. A focal point past the radius, which an elliptical gradient
written circular can have, is written on the circle, so its x and y
offsets agree with the fraction 1 written after them (section 3.5.5).
Linear instances are unchanged. The result was not checked in the
reference app.
2026-10-18 12:16:41 +07:00
dotCooCoo d7e79448d7 Legacy .ai export: warns when an image in art it flattens or expands can't be decoded
Saving a version 7 .ai expands raster effects into images and flattens
images with see-through pixels, opacity or a blending mode, and both
draw the art with the renderer. An image the renderer couldn't decode
drew nothing, so it was missing from the result, and the reply carried
no note. A semi-transparent image cut short was removed without one.

The save now compares decode_failures() before and after it writes the
artboards, as Expand Appearance and the raster exports do, and adds
"an image couldn't be decoded and is missing from the picture" to its
warnings once.

Docs: docs/mcp.md (aiVersion "7").
2026-10-18 12:16:40 +07:00
dotCooCoo d66077a15f .ai import: page type over a text object is left out only when that object's text has its letters
When a .ai or EPS file opens from its editing data, the import left
out the page's type that no text object owns whenever the regions of
the text objects made from the text document covered at least half of
it, without comparing letters. The region of point type reaches its
longest line's length in characters times its size, plus its size, to
the left and right, and one and a half times its size above and below.
A 21-character line at 10 pt reaches 220 pt to each side. The page's
other type inside that box was lost.

Now the region of a made text object counts toward that half only
when its story has the Latin letters and digits of one of the type's
pieces, compared without case, and only when such stories between
them have the letters of every piece. Only those text objects count
as drawn by the page. The check that decides whether the text
document is trusted still counts every region.

Docs: docs/target-app-parity.md.
2026-10-18 12:16:39 +07:00
dotCooCoo 4eef9b7e8c EPS and .ai import: the note for editing data of another page size gives both sizes and the scale
When the editing data of an EPS or .ai file was for a page of another
size than the file's own page, the file opened as its page or its PDF
part with the reason "its editing data is for a different page". The
reason did not say which page the editing data was for, or that it was
the same page at another scale.

The reason now gives both sizes in points: "its editing data is for a
600 × 400 pt page, not the file's 612 × 792 pt page". When the two
pages are in proportion (within the 0.5 pt per side of the check), it
gives the scale of the editing data's page to the file's instead:
"its editing data is for a 100 × 100 pt page, the file's
1000 × 1000 pt page at 1:10". Every catalog that had the old reason
has rows for both new ones.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:15:39 +07:00
dotCooCoo aafe102649 .ai import: a file whose editing data can't be decompressed says why its layers weren't read
A PDF-compatible .ai, or a PDF saved with the editing data, whose
editing data can't be decompressed (damaged zlib or Zstandard data, or
data that decompresses to more than 256 MB) opened from its PDF part
with no note about its layers. layered_ai returned on the
decompression error without a note. Data that decompresses to layers
that can't be read gets one, and so does an EPS.

layered_ai now gives the same note with the decompression error as
its reason: "the file's layers weren't read from its editing data (its
zlib compression is damaged): they come from its PDF part". The note
counts as a loss, so document.export or Save As over a PDF saved with
the editing data asks first (acknowledgeLoss), as it already did for
a .ai. The three reasons have rows in the 17 catalogs that translate
every message.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:15:32 +07:00
dotCooCoo ffc12babf2 PDF import: PDF and .ai layer names without a byte order mark are read as PDFDocEncoding
The layers of PDF and PDF-compatible .ai files come from their
optional content groups, whose /Name is a PDF text string. The import
read a name without a byte order mark as Latin-1. Such a string is
PDFDocEncoding (ISO 32000-1 Annex D, Table D.2), which differs from
Latin-1 at 0x18 to 0x1F and 0x80 to 0xA0. A group named
(Art\205final) opened as a layer named "Art", U+0085, "final" instead
of "Art–final", and curly double quotes (0x8D, 0x8E) and the euro sign
(0xA0) became control characters and a no-break space.

text_string now reads such a string with the PDFDocEncoding table that
File Info already used for the document information dictionary. The
table moves from file_info.rs to import_scan.rs, and File Info reads
the information dictionary's strings with text_string.
2026-10-18 12:15:31 +07:00
dotCooCoo 830cefcaf6 PDF import: an XMP packet nested more than 256 deep is skipped instead of overflowing the stack
When a PDF, PDF-compatible .ai or .ait opens, crates/pdf/src/file_info.rs
reads its File Info from the XMP packet with roxmltree, which parses
nested elements recursively and has no limit on their depth. A packet
with elements nested 10,000 deep (70 KB) overflowed a 1 MB stack, and
one nested 100,000 deep (700 KB) overflowed an 8 MB stack. A stack
overflow aborts the app.

File Info now counts how deep the packet's elements nest before roxmltree
parses it, and leaves out a packet nested more than 256 deep, as it
leaves out a packet that is not XML. The document information
dictionary then gives the File Info.
2026-10-18 12:15:31 +07:00
dotCooCoo bdf6059155 MCP: parse errors and overlong lines wait for room in the 64-line queue
The MCP server's reader queues at most 64 lines for the engine and
stops reading past that until the engine catches up. The reply to a
line that is not JSON, or that is longer than the line limit, skipped
that check: while a call had run for less than the busy limit (two
seconds by default), or while the engine was behind, every such line
added a reply to the queue. In the new test, 192 lines that are not
JSON leave 192 replies queued on main.

These replies now wait for room as requests do, and still go out at
once when the running call has passed the busy limit.
2026-10-18 12:15:30 +07:00
dotCooCoo c73945d244 SVG import: hrefs that need no folder stay as written, and images in a non-absolute folder are read
SVG import checks each linked image's path with craft-docpath, and two
of its results were wrong, most visibly on Windows. A missing image's
link kept the path that craft-docpath returned, with its separators
rewritten: an href of /abs/photo.png became \abs\photo.png on Windows,
and SVG export with linked images writes that back as the href. A
folder that is not absolute, such as /tmp on Windows or a relative
folder on any system, made each relative href, and on Windows each
href rooted without a drive, resolve to a path that is not absolute,
so those images were not read. Two vectorcraft-svg tests failed on
Windows because of this.

A link now keeps the href as written (decoded) when the href resolves
to the same path without the SVG's folder, and keeps the path found
when it needs the folder: a relative path, or on Windows a path rooted
without a drive. A file: URL keeps the path it names. The folder is
made absolute against the working directory when it is not, as the
engine's reader does with the document's path. Hrefs that were refused
are still refused: paths on other computers, devices and URLs are not
read.
2026-10-18 12:15:29 +07:00
Brandon ThomasandClaude Opus 5.5 904f0fe522 Release 0.13.1: retry stapler validate on Apple network errors
0.13.0's macOS release job failed twice at `stapler validate` right after a successful notarization
and staple: the CloudKit ticket lookup lost its connection (NSURLErrorDomain -1005, Error 68). Retry
the validate up to four times with a growing pause; a missing or bad ticket still fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:00:06 -04:00
Brandon ThomasandClaude Opus 5.5 5e6810655f Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:43:14 -04:00
skylar taylor-barrick 299a4b36ad Merge pull request #2777 from skylartaylor/open-original
Open originals and linked files only on this computer or the document's share
2026-10-17 15:45:22 -06:00
skylar taylor-barrick 6a83f6fea6 i18n: translate the new link messages into Simplified Chinese
The FreeBSD job failed complete_languages_translate_every_message: zh-hans was
missing the four Links and Attributes messages this branch adds.

The merge created a state neither side had. This branch predates main adding
zh-hans to COMPLETE_MESSAGES, so its new messages had no Simplified Chinese; the
other 17 languages conflicted and were resolved as a union, while zh-hans merged
cleanly and silently without them. da, id, nl and zh-hant are also missing them
but are not on the completeness list, so they are left as they are.

Terminology follows the file's own: 编辑原稿 for Edit Original, 浏览器 for
browser, 不是文件 after {path} 不是文件夹. These want a native speaker's eye
before release; the test only proves the strings exist.
2026-10-17 13:48:41 -06:00
BFlat e6aec0664c Merge pull request #2791 from storytold/pr/batch-1018
Docs: ROADMAP entry and parity-row cleanup for the 2026-10-18 community batch
2026-10-18 01:57:47 +07:00
skylar taylor-barrick d44dc983e6 Merge storytold/main into open-original
Conflicts were 17 i18n .tsv files where both sides appended new strings;
resolved as a union, verified to introduce no duplicate keys.
2026-10-17 12:15:10 -06:00
skylar taylor-barrick 39f9204d57 Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:03:53 -06:00
aiongg 82e363c73b Build: use craft-docpath from craft-libs 2026-10-17 02:20:38 +00:00
aiongg b05043c759 i18n: translate the new link and file-name messages 2026-10-16 21:00:40 +00:00
aiongg ef5084f48b Links: follow linked file paths only on this computer or the document's share
Paths stored with linked images and placed documents, and the hrefs of
SVG images, go through fileio::document_path (craft-docpath): a relative
path is taken from the document's folder, and only local files or files
on the document's own network share are looked at. Opening a document,
Link Info, Package, the linked-file watch and SVG import all use it; the
watch stats files through fileio, so automation roots apply to it too.

A path that is not followed counts as missing; document.open,
links.check and links.info say why in `refused`.
2026-10-16 17:55:48 +00:00
aiongg 1ddfb56f66 Attributes: the Browser button opens http, https and mailto URLs only
attributes.openUrl refuses other URL schemes with a message. It and
links.editOriginal hand a URL or file to another app, so they answer a
control or MCP request (and synthetic input) with an error; a placed
document still opens in a tab.
2026-10-16 16:52:19 +00:00
aiongg b46ffd0059 Links: Edit Original opens only regular files of a format VectorCraft reads
A folder or app bundle, a link to another kind of file, or a file with
any other extension is refused with a message instead of being handed to
the system's default app. On Windows, a path containing a quotation mark
is refused before it reaches Explorer.
2026-10-16 16:39:46 +00:00
612 changed files with 68501 additions and 4796 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/crates/affinity/fuzz"
- "/crates/plugins/example"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -35,5 +35,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
run-package-check: true
+2 -1
View File
@@ -26,7 +26,8 @@ jobs:
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+1
View File
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
Generated
+295 -302
View File
File diff suppressed because it is too large. Load diff
+12 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,7 +13,11 @@ 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-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -21,8 +25,9 @@ vectorcraft-doc = { path = "crates/doc" }
vectorcraft-pathops = { path = "crates/pathops" }
vectorcraft-brush = { path = "crates/brush" }
vectorcraft-trace = { path = "crates/trace" }
# 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.
# Without its default `bundled-fonts`: a crate that wants the fonts asks for them (the apps and
# vectorcraft-testkit through the engine's `bundled-fonts`), 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" }
@@ -31,7 +36,9 @@ vectorcraft-svg = { path = "crates/svg" }
vectorcraft-pdf = { path = "crates/pdf" }
vectorcraft-format = { path = "crates/format" }
vectorcraft-tools = { path = "crates/tools" }
vectorcraft-engine = { path = "crates/engine" }
# Without its default `bundled-fonts`, as vectorcraft-text: each crate that wants the fonts asks for
# them, so an embedder can build without them (docs/development.md › Leaving out the bundled fonts).
vectorcraft-engine = { path = "crates/engine", default-features = false }
vectorcraft-ui-egui = { path = "crates/ui-egui" }
vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" }
@@ -64,7 +71,7 @@ wat = "1.228"
# 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"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
# 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.
+31
View File
@@ -92,3 +92,34 @@ Assets already bundled through Hayro's default features retain their licences:
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.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22 -18
View File
@@ -11,20 +11,23 @@
<h1 align="center">VectorCraft</h1>
<p align="center">
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
A fast, open-source vector illustration workflow. It runs natively on
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
</p>
<p align="center">
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f?style=flat-square">
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?style=flat-square&logo=rust&logoColor=white">
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555?style=flat-square">
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_vectorcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/vectorcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -147,7 +150,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Why VectorCraft
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
full retina resolution while the interface stays at 120 fps.
@@ -163,6 +166,8 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Quick start
For posters and sewing patterns, see [Print a large poster across multiple pages](docs/print-tiling.md).
```sh
git clone https://github.com/storytold/vectorcraft
cd vectorcraft
@@ -178,6 +183,8 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
```
For text and custom letter shapes, see [Typography and Custom Lettering](docs/typography-lettering.md).
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
@@ -224,9 +231,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
all of Illustrator): everyday
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): 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, 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).
@@ -238,22 +244,22 @@ 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, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
The scores are self-assessed from our code against public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
**What's missing:**
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- most of the raster effects (9 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper under Sketch) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
[`docs/gaps.md`](docs/gaps.md).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
@@ -372,8 +378,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+19 -7
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (about 40 community PRs of 2026-10-18: Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; 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
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 54/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
| Live vector effects | 85% | 6–10 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 8–14 |
| 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@@ -112,8 +112,8 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | full | 100% |
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).
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -123,18 +123,26 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
5. **Raster effects** (G2) alongside: the 10 remaining filters (7 Artistic, 2 Sketch, 1 more) and the Effect Gallery, 8–14 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
- 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-18 | About 40 community PRs landed in one batch through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. |
| 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. |
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
@@ -163,6 +171,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
| 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
@@ -180,9 +189,12 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
| 2026-10-17 | minor | Progress log row for the Background Export progress dialog (#2564); headline numbers unchanged. |
| 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. |
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-engine = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-format = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
+49 -13
View File
@@ -78,22 +78,27 @@ USAGE:
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats). Prints one
JSON result per step.
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
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] [--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
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
out. --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.
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
--artboard or --range).
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
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. The
--replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
even where that draws differently from its PDF part or page (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
@@ -259,6 +264,10 @@ fn convert(args: &[String]) -> Result<(), String> {
}
}
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let template = is_template(&output);
if template && (artboard.is_some() || range.is_some()) {
return Err("a .ait template holds every artboard: --artboard and --range don't apply".into());
}
let mut h = Headless::new();
let opened = if editing_data {
let params = json!({"path": input, "editingData": "always"});
@@ -266,16 +275,23 @@ fn convert(args: &[String]) -> Result<(), String> {
} else {
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 (command, mut params) = if template {
// Save as Template writes every artboard, with the .ai options.
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
} else {
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale);
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
("document.export", params)
};
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}"))?;
let mut r = h.call("engine.execute", json!({"command": command, "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()) {
@@ -285,6 +301,21 @@ fn convert(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Is `path` a `.ait` template? `file.saveAsTemplate` writes it; exports don't.
fn is_template(path: &str) -> bool {
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
}
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
/// its units unset.
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
params["scale"] = json!(scale);
}
params
}
fn info(args: &[String]) -> Result<(), String> {
let file = args.first().ok_or("info needs a FILE")?;
let mut h = Headless::new();
@@ -371,7 +402,12 @@ fn run(args: &[String]) -> Result<(), String> {
r
}
Step::Export(path) => {
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
let r = if is_template(&path) {
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
} else {
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
};
let r = r.map_err(|e| format!("export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
+22
View File
@@ -58,6 +58,28 @@ fn run_batch_exports() {
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
}
#[test]
fn scale_sizes_raster_exports_only() {
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
// resize the drawing and leave its units unset.
let svg = tmp("scale.svg");
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
.unwrap();
let svg = svg.to_str().unwrap();
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
let ok = |args: &[&str]| {
let out = Command::new(BIN).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
ok(&["convert", svg, plain.to_str().unwrap()]);
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
// 100 pt at scale 2 → 200 px (IHDR width).
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
}
#[test]
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
+25
View File
@@ -179,3 +179,28 @@ fn convert_writes_legacy_ai_with_its_options() {
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
}
/// convert and `run --export` write a `.ait` as Save as Template does: a PDF-compatible template of
/// every artboard that opens as a new untitled document.
#[test]
fn convert_and_run_write_ait_templates() {
let pdf = tmp("template-in.pdf");
make(&pdf, 120, 80);
let (converted, exported) = (tmp("converted.ait"), tmp("exported.ait"));
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &converted])).unwrap();
assert_eq!((&r["format"], &r["path"]), (&Value::from("ait"), &Value::from(converted.as_str())), "{r}");
let r: Value = serde_json::from_str(ok(&["run", "--in", &pdf, "--export", &exported]).lines().nth(1).unwrap()).unwrap();
assert_eq!(r["result"]["format"], "ait", "{r}");
for ait in [&converted, &exported] {
assert!(std::fs::read(ait).unwrap().starts_with(b"%PDF"), "{ait}");
let opened: Value = serde_json::from_str(ok(&["run", "--in", ait]).lines().next().unwrap()).unwrap();
let opened = &opened["result"];
assert_eq!((&opened["format"], &opened["restored"]), (&Value::from("ait"), &Value::from(true)), "{opened}");
assert!(opened["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{opened}");
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
}
// A template holds every artboard: one can't be picked.
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
assert!(!one.status.success());
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
}
+2 -1
View File
@@ -14,7 +14,7 @@ name = "vectorcraft-web"
path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
@@ -26,6 +26,7 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
serde_json = { workspace = true }
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", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+184
View File
@@ -0,0 +1,184 @@
//! `window.vectorcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
//! await vectorcraft.request("ui.inspect") // control method → its result
//! await vectorcraft.commands() // command list
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
//! vectorcraft.captureDownloads(true) // saves stay in the page…
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
use crate::web::Inbox;
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
thread_local! {
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
if let Some(tx) = tx {
TX.with(|t| *t.borrow_mut() = Some(tx));
}
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
fn repaint() {
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(path: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(v) => {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
} else {
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
}
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
if sent {
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
} else {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
}
});
repaint();
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.vectorcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
let queued = INBOX.with(|i| {
i.borrow().as_ref().is_some_and(|inbox| {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
true
})
});
repaint();
JsValue::from_bool(queued)
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
Ok(())
}
+4
View File
@@ -16,12 +16,16 @@
//! 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.
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+16 -2
View File
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
use crate::pencil::SharedPointers;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
/// whether Shift was held: where dropped files land.
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The old services asked the old context for frames.
app.services = services;
app.status("The graphics were lost and have been restarted");
crate::api::connect(None, host.inbox.clone(), ctx.clone());
app
}
None => VectorcraftApp::new(Session::new(), services),
None => {
// `window.vectorcraft`: the page's control channel (crate::api).
let (control_tx, control_rx) = std::sync::mpsc::channel();
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
}
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
}
};
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
}),
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
}
}
app.logic(ctx);
crate::api::poll_replies();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
if let Some(app) = &mut self.app {
app.ui(ui);
}
crate::api::poll_replies();
}
}
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
if crate::api::capture(path, bytes) {
return Ok(());
}
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
let window = web_sys::window().ok_or("no window")?;
+20 -2
View File
@@ -17,9 +17,12 @@ accessibility = ["eframe/accesskit"]
windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217.
craft-window = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
@@ -72,10 +75,23 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std"
pollster = "1"
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME/Wayland needs client-side window controls without a bundled title font.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
# exactly: review the diff before bumping.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
zbus = "=5.19.0"
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
@@ -84,3 +100,5 @@ winresource = "0.1"
[dev-dependencies]
# Bound socket buffers in real loopback transport deadline regressions.
socket2 = { workspace = true }
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
zbus = "=5.19.0"
+25 -24
View File
@@ -26,9 +26,7 @@ use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePan
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
use vectorcraft_ui_egui::{FileFilter as Filter, FilePick, i18n};
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
@@ -70,7 +68,8 @@ define_class!(
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
let named: Vec<Filter> =
filters.iter().filter(|(_, extensions)| !extensions.is_empty() && !extensions.iter().any(|x| x == "*")).cloned().collect();
if named.len() < 2 { Vec::new() } else { named }
}
@@ -95,10 +94,10 @@ fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| extensions.as_slice());
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
first.iter().chain(others).map(String::as_str).collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
@@ -184,26 +183,28 @@ fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, s
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
fn filters() -> Vec<Filter> {
FilePick::filters([("VectorCraft", &["vectorcraft"][..]), ("SVG", &["svg"]), ("PDF", &["pdf"])])
}
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
let save = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
assert_eq!(save.first().map(|(_, e)| e.as_slice()), Some(&["vectorcraft".to_owned()][..]));
assert!(menu_formats(&FilePick::filters(fileio::save_filters("svg"))).first().is_some_and(|(_, e)| e.iter().any(|e| e == "svg")));
assert!(menu_formats(&FilePick::filters(fileio::template_filters("template"))).len() >= 2);
assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..])])).is_empty());
assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("Any", &[])])).is_empty());
assert_eq!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("SVG", &["svg"])])).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
assert_eq!(initial_format(name, &filters()), index, "{name}");
}
}
@@ -219,7 +220,7 @@ mod tests {
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
assert_eq!(filename(name, "svg", &filters()), expected, "{name}");
}
}
@@ -227,19 +228,19 @@ mod tests {
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(&filters(), 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(&filters(), 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(&filters(), 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
let filters = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
let name = filename("Untitled-1.vectorcraft", &extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}");
}
}
+251 -31
View File
@@ -43,6 +43,8 @@ mod mac_screen_color;
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
@@ -142,11 +144,7 @@ impl App {
// again on its document when it answers.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() {
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
if !self.app.file_dialog_open() {
open_files(&mut self.app, open_documents::take());
}
open_files(&mut self.app, open_documents::take());
}
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
@@ -249,10 +247,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
// Saved workspaces and the layout changes kept for each workspace carry the same layouts.
for key in ["custom_workspaces", "workspace_changes"] {
if let Some(Value::Array(workspaces)) = object.get_mut(key) {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
}
@@ -291,7 +292,7 @@ type Parent = Option<std::sync::Arc<winit::window::Window>>;
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[&str]) -> Self;
fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
@@ -305,7 +306,7 @@ macro_rules! dialog_settings {
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[&str]) -> Self {
fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
@@ -331,10 +332,9 @@ fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
}
PickRequest::Folder => return d,
};
let d = pick
.filters
.iter()
.fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts));
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
None => d,
@@ -359,10 +359,20 @@ fn make_folder(request: &PickRequest) {
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
/// the portal can't.
#[cfg(not(target_os = "macos"))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
#[cfg_attr(windows, allow(unused_variables))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
make_folder(request);
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
if let PickRequest::Save(pick) = request
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
{
return answer;
}
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
@@ -375,7 +385,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
show_dialog(file_dialog(&request, parent), &request, parent)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
@@ -389,6 +399,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
explorer_safe(path)?;
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
@@ -430,11 +441,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
let parent = parent.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
let _ = tx.send(show_dialog(dialog, &request, &parent));
ui.request_repaint();
})
.ok()?;
@@ -559,8 +571,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
}
}
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
/// no Windows file name has) is refused rather than split into other arguments.
fn explorer_safe(path: &str) -> Result<(), String> {
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
explorer_safe(path)?;
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
@@ -608,13 +627,73 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
/// macOS keeps its traffic lights over the integrated title strip.
///
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
/// before the window opens; a missing or unreadable value keeps the default.
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on.
fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference always forces the native frame back on.
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
}
/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
/// Windows draws its own caption unless the user opted into the system title bar.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
CUSTOM_TITLEBAR && !opted_into_system
}
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference. Call once at startup, before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
let opted_into_system = opted_into_system_title_bar(saved);
#[cfg(target_os = "linux")]
{
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
// present, the same two conditions the launch path checks below — that run is effectively
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
// the probe's own decision (app-drawn on GNOME) must stand.
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
if choice.overridden().is_none() && forced_to_x11 {
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
}
}
custom_titlebar_from(choice.frame(), opted_into_system)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
@@ -989,6 +1068,7 @@ mod tests {
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
@@ -999,6 +1079,8 @@ mod tests {
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
@@ -1015,6 +1097,8 @@ mod tests {
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
assert!(loaded.workspace_changes[0].docking.is_none());
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
@@ -1027,17 +1111,41 @@ mod tests {
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
// decorations on Windows and Linux; macOS always has them.
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
use craft_window::{Frame, Layout};
// The System Title Bar preference (Preferences › User Interface) is read the same way on
// every platform; a missing or unreadable value is `false`.
assert!(!super::opted_into_system_title_bar(None), "no preferences: the default");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
assert!(!super::opted_into_system_title_bar(Some(&off)));
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
assert!(!super::custom_titlebar(Some(&on)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
if super::CUSTOM_TITLEBAR {
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
assert!(super::opted_into_system_title_bar(Some(&on)));
// The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title
// bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native
// decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws
// our own title bar — unless the user opted into the system title bar, which forces the
// native frame back on.
let app = Frame::App(Layout::default());
let desktop = Frame::Desktop;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own");
assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native");
assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame");
assert!(!super::custom_titlebar_from(&desktop, true));
}
// macOS and Windows keep today's behavior, independent of the probed frame: the pref still
// forces the native frame back on where a custom one would otherwise be drawn.
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR);
assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows");
assert!(!super::custom_titlebar_from(&desktop, true));
}
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
@@ -1069,4 +1177,116 @@ mod tests {
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
}
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
/// declares.
const LINUX_TYPES: &[(&str, &str)] = &[
("vectorcraft", "application/x-vectorcraft"),
("drawcraft", "application/x-vectorcraft"),
("svg", "image/svg+xml"),
("svgz", "image/svg+xml-compressed"),
("pdf", "application/pdf"),
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
("ait", "application/x-ai-template"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("tif", "image/tiff"),
("tiff", "image/tiff"),
("bmp", "image/bmp"),
("vctemplate", "application/x-vectorcraft-template"),
("dxf", "image/vnd.dxf"),
("emf", "image/emf"),
("wmf", "image/wmf"),
("eps", "image/x-eps"),
("af", "application/x-affinity"),
("afdesign", "application/x-affinity"),
("afpub", "application/x-affinity"),
];
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
/// other one.
const LINUX_SHARED_TYPES: &[&str] = &[
"image/svg+xml",
"image/svg+xml-compressed",
"application/pdf",
"application/illustrator", // brand-ok: shared-mime-info's type
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/vnd.dxf",
"image/emf",
"image/wmf",
"image/x-eps",
];
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
"af",
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
)];
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
/// have.
#[test]
fn the_linux_desktop_entry_opens_every_readable_format() {
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
let listed: Vec<&str> =
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
let declared: Vec<(&str, Vec<&str>, String)> = mime
.split("<mime-type type=\"")
.skip(1)
.map(|block| {
let body = block.split("</mime-type>").next().unwrap_or_default();
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
let magic = match (body.find("<magic"), body.find("</magic>")) {
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
_ => String::new(),
};
(block.split('"').next().unwrap_or_default(), globs, magic)
})
.collect();
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
associated.sort_unstable();
typed.sort_unstable();
typed.dedup();
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
for (e, t) in LINUX_TYPES {
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
let package = declared.iter().find(|(name, ..)| name == t);
if LINUX_SHARED_TYPES.contains(t) {
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
continue;
}
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
} else {
assert!(globs.contains(e), "{t} has no *.{e} glob");
}
}
for t in &listed {
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
}
for (t, globs, _) in &declared {
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
for g in globs {
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
}
}
}
}
+533
View File
@@ -0,0 +1,533 @@
//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a
//! Copy, Save as Template; #2679), asked of the XDG desktop portal here
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer
//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the
//! user picks another file type, so the type picked was lost. This call sends the first file type as
//! `current_filter`, reads the one picked back, and answers its first extension after the path
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
//!
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
use std::collections::HashMap;
use vectorcraft_ui_egui::FilePick;
use zbus::zvariant::{OwnedValue, Value};
/// A file type in the dialog: its label and its extensions without the dot.
type Filter = (String, Vec<String>);
/// A save dialog to show.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct SaveRequest {
/// The portal's identifier of the parent window, or empty.
parent: String,
/// The suggested name; empty for none.
name: String,
folder: Option<String>,
/// The file types, labeled in the interface's language; the dialog starts on the first.
filters: Vec<Filter>,
}
impl SaveRequest {
/// The dialog for `pick` over the window `parent` names.
fn new(pick: &FilePick, parent: String) -> Self {
let lang = vectorcraft_ui_egui::i18n::current();
let filters = pick
.filters
.iter()
.map(|(label, exts)| {
(vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect())
})
.collect();
Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters }
}
}
/// What the dialog answered: the path, as typed, and the label of the file type picked.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Saved {
path: String,
filter: Option<String>,
}
/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for
/// every file), as rfd sends them.
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
(filter.0.clone(), globs)
}
/// The `SaveFile` options for `request`, answering on `token`.
fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
let mut o = HashMap::from([("handle_token", Value::from(token))]);
if !request.name.is_empty() {
o.insert("current_name", Value::from(request.name.as_str()));
}
if let Some(folder) = &request.folder {
// A byte string ending in NUL, as the portal specifies.
let mut bytes = folder.as_bytes().to_vec();
bytes.push(0);
o.insert("current_folder", Value::from(bytes));
}
if let Some(first) = request.filters.first() {
o.insert("filters", Value::from(request.filters.iter().map(wire).collect::<Vec<_>>()));
o.insert("current_filter", Value::from(wire(first)));
}
o
}
/// The portal's `Response`: code 0 with the first of its `uris` and the label of its
/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed
/// some other way), as rfd reads it.
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
if code != 0 {
return Ok(None);
}
let uris: Vec<String> = results
.get("uris")
.ok_or("no file in the portal's answer")?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or("no file in the portal's answer")?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
Ok(Some(Saved { path, filter }))
}
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
/// or another scheme.
fn file_uri_path(uri: &str) -> Option<String> {
let rest = uri.strip_prefix("file://")?;
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let mut out = Vec::with_capacity(rest.len());
let mut rest = rest.as_bytes();
while let Some((&b, tail)) = rest.split_first() {
let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit));
match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(decoded) => {
out.push(decoded);
rest = tail.get(2..).unwrap_or_default();
}
None => {
out.push(b);
rest = tail;
}
}
}
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
Some(String::from_utf8_lossy(&out).into_owned())
}
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
/// the file type picked, found by its label. Empty when the dialog was canceled.
fn pick_answer(saved: Option<Saved>, request: &SaveRequest) -> Vec<String> {
let Some(saved) = saved else { return Vec::new() };
let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned());
std::iter::once(saved.path).chain(picked).collect()
}
/// The portal's identifier of an X11 window.
fn x11_parent(window: impl std::fmt::LowerHex) -> String {
format!("x11:{window:x}")
}
/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the
/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't
/// show the dialog; rfd shows it then.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<Vec<String>> {
if pick.filters.len() < 2 {
return None;
}
// The export stays until the dialog has answered: the handle is valid while it lives.
let (parent, _export) = window.map(parent).unwrap_or_default();
let request = SaveRequest::new(pick, parent);
let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request));
match saved {
Ok(saved) => Some(pick_answer(saved, &request)),
Err(e) => {
log::info!("the portal's save dialog failed ({e}): rfd shows it");
None
}
}
}
/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
use std::sync::atomic::{AtomicU32, Ordering};
use zbus::blocking::Proxy;
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
// Listen before asking, so that an answer that comes at once is not missed.
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
// returns.
let reply = if handle.as_str() == path {
replies.next()
} else {
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
}
.ok_or("the save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
/// The portal's identifier of `window` when it is an X11 window.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn x11_window(window: &winit::window::Window) -> Option<String> {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
match window.window_handle().ok()?.as_raw() {
RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)),
RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())),
_ => None,
}
}
/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when
/// the window has neither an X11 id nor a Wayland handle.
#[cfg(target_os = "linux")]
fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
if let Some(id) = x11_window(window) {
return (id, None);
}
match wayland::export(window) {
Some(export) => (format!("wayland:{}", export.handle), Some(export)),
None => (String::new(), None),
}
}
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
/// Linux).
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn parent(window: &winit::window::Window) -> (String, Option<()>) {
(x11_window(window).unwrap_or_default(), None)
}
/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
/// makes it.
#[cfg(target_os = "linux")]
mod wayland {
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::protocol::wl_surface::WlSurface;
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2};
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2};
/// A window exported for the portal: `handle` names it until this is dropped.
pub struct Export {
pub handle: String,
exported: ZxdgExportedV2,
conn: Connection,
}
impl Drop for Export {
fn drop(&mut self) {
self.exported.destroy();
let _ = self.conn.flush();
}
}
/// The events the export waits for.
#[derive(Default)]
struct State {
handle: Option<String>,
}
impl Dispatch<WlRegistry, GlobalListContents> for State {
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZxdgExporterV2, ()> for State {
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZxdgExportedV2, ()> for State {
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
if let zxdg_exported_v2::Event::Handle { handle } = event {
state.handle = Some(handle);
}
}
}
/// Export `window`'s surface on the window's own Wayland connection, with an event queue of its
/// own → `None` when the compositor has no xdg-foreign v2 or answers no handle.
pub fn export(window: &winit::window::Window) -> Option<Export> {
let (conn, surface) = surface(window)?;
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
let qh = queue.handle();
let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?;
let exported = exporter.export_toplevel(&surface, &qh, ());
let mut state = State::default();
let answered = queue.roundtrip(&mut state);
exporter.destroy();
match (answered, state.handle) {
(Ok(_), Some(handle)) => Some(Export { handle, exported, conn }),
_ => {
exported.destroy();
let _ = conn.flush();
log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window");
None
}
}
}
/// `window`'s Wayland connection and surface, from its raw handles.
#[allow(unsafe_code)]
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
use wayland_backend::sys::client::{Backend, ObjectId};
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
(window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw())
else {
return None;
};
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
// holds it until the export is dropped); a backend made from a foreign display does not
// close it when dropped.
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
let conn = Connection::from_backend(backend);
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?;
let surface = WlSurface::from_id(&conn, id).ok()?;
Some((conn, surface))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> SaveRequest {
let pick = FilePick {
name: "Untitled-1.vectorcraft".into(),
folder: Some("/home/me/Art".into()),
filters: vec![
FilePick::filter("VectorCraft", &["vectorcraft"]),
FilePick::filter("SVG", &["svg"]),
FilePick::filter("PDF-compatible .ai", &["ai"]),
],
};
SaveRequest::new(&pick, "x11:3a0000f".into())
}
/// The dialog starts on the first file type (the document's format) and shows the suggested
/// name in its folder.
#[test]
fn the_request_names_the_file_type_the_dialog_starts_on() {
let request = request();
let o = options(&request, "t1");
assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]))));
let filters = vec![
("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]),
("SVG".to_string(), vec![(0u32, "*.svg".to_string())]),
("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]),
];
assert_eq!(o.get("filters"), Some(&Value::from(filters)));
assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft")));
assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec())));
assert_eq!(o.get("handle_token"), Some(&Value::from("t1")));
let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request };
let o = options(&bare, "t2");
assert_eq!(o.keys().copied().collect::<Vec<_>>(), ["handle_token"]);
}
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
let mut r = HashMap::new();
r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap());
if let Some(label) = filter {
let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())]));
r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap());
}
r
}
/// The file type picked comes back with the path as typed, and its first extension follows
/// the path in the answer.
#[test]
fn the_answer_carries_the_file_type_picked() {
let request = request();
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap();
assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) }));
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
// A portal that names no file type, or one the dialog did not offer: the path alone.
let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]);
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
}
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
/// is an error, and rfd shows the dialog instead.
#[test]
fn a_canceled_dialog_answers_no_file() {
let request = request();
for code in [1, 2] {
assert_eq!(answer(code, &HashMap::new()), Ok(None));
assert!(pick_answer(None, &request).is_empty());
}
assert!(answer(0, &HashMap::new()).is_err());
assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err());
}
#[test]
fn file_uris_become_local_paths() {
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
// A `%` without two hex digits after it stays as it is.
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
assert_eq!(file_uri_path("file://host/x.svg"), None);
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
}
#[test]
fn x11_windows_are_named_by_their_id() {
assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f");
}
/// What the mock portal answers to each `SaveFile` call: the response code, and the file and
/// the label of the file type "picked".
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
/// What each `SaveFile` call carried: `current_name` and the label of `current_filter`.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
/// is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead as _;
use zbus::zvariant::OwnedObjectPath;
let mut daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn()
.ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server =
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
let log = seen.clone();
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
continue;
}
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let filter = options.get("current_filter").and_then(|v| {
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
let calls = log.lock().map(|mut l| {
l.push((text("current_name"), filter));
l.len()
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
if server.reply(&header, &handle).is_err() {
break;
}
if own_paths {
// As a person answers the dialog: after the caller listened on the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
results.insert("uris", Value::from(vec![uri.to_string()]));
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
let to = header.sender().cloned().map(zbus::names::BusName::from);
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
break;
}
}
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, seen, daemon))
}
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
/// name, and the answer names the file and the type picked; Cancel answers no file.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
let saved = save_file(&conn, &request).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
assert_eq!(save_file(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
let _ = daemon.kill();
}
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
/// there.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
let _ = daemon.kill();
}
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
#[test]
fn the_file_types_are_labeled_as_the_other_dialogs_label_them() {
let request = request();
let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect();
let lang = vectorcraft_ui_egui::i18n::current();
let expected: Vec<&str> =
["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect();
assert_eq!(labels, expected);
assert_eq!(request.parent, "x11:3a0000f");
}
}
+3 -1
View File
@@ -25,7 +25,9 @@ fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
// Long enough for the binary to start on a loaded machine (the workspace's other tests run
// meanwhile), short enough to catch a desktop that started instead of exiting.
let deadline = Instant::now() + Duration::from_secs(30);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="10" height="10" fill="currentColor" fill-opacity="0.4"/><rect x="10" y="10" width="10" height="10"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="7" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 7h10"/><circle cx="6" cy="12" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 12h8"/><circle cx="6" cy="17" r="2" fill="currentColor" fill-opacity="0.45"/><path d="M10 17h11"/></svg>

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 460 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="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>

After

Width:  |  Height:  |  Size: 369 B

+25
View File
@@ -127,5 +127,30 @@ sync_github_name = true
[people.dennismutuku2005]
sync_github_name = true
[people.krinry]
display_name = "krinry"
sync_github_name = true
[people.makesomethingshit]
sync_github_name = true
[people.MiQ71]
real_name = "Mirko Kerezovic"
sync_github_name = false
[people.KaboomFox]
sync_github_name = true
[people.odysseexp]
real_name = "Harley Paiva"
sync_github_name = true
[people.addibble]
sync_github_name = true
[people.mockydevs]
real_name = "Don Omondi"
display_name = "Don Omondi"
[people.alpha-rock]
display_name = "Aditya Sisodiya"
+24 -4
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Node};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use crate::Colorization;
@@ -43,7 +43,8 @@ fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
}
/// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone.
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. Type is
/// recolored like paths: its characters' solid fills and strokes as well as its own.
pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
let key = match (mode, stroke.color()) {
(Colorization::None, _) | (_, None) => None,
@@ -65,6 +66,12 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
}
}
}
if let (Some(k), NodeKind::Text(t)) = (&key, &mut n.kind) {
for r in &mut t.runs {
map_paint(&mut r.style.fill, mode, k);
map_paint(&mut r.style.stroke, mode, k);
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
walk(Arc::make_mut(c), mode, key);
@@ -88,8 +95,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if let Paint::Solid { color: c, .. } = &mut r.style.fill {
*c = c.lerp(color, amount);
for p in [&mut r.style.fill, &mut r.style.stroke] {
if let Paint::Solid { color: c, .. } = p {
*c = c.lerp(color, amount);
}
}
}
}
@@ -119,6 +128,8 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
/// Type's characters count as well: each run's fill, and its stroke when the stroke is wider
/// than 0.
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
for it in &n.appearance.items {
@@ -131,6 +142,15 @@ pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
if let NodeKind::Text(t) = &n.kind {
for r in &t.runs {
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
for c in [Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color) {
let order = seen.len();
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
}
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
+32 -7
View File
@@ -287,6 +287,8 @@ pub struct ArtBrush {
pub width: f64,
pub flip_along: bool,
pub flip_across: bool,
/// When true, art is placed on each run between sharp corners so folds do not overlap.
pub overlap: bool,
pub colorization: Colorization,
}
@@ -304,6 +306,7 @@ impl Default for ArtBrush {
width: 100.0,
flip_along: false,
flip_across: false,
overlap: false,
colorization: Colorization::Tints,
}
}
@@ -410,6 +413,8 @@ impl Bristle {
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
/// The most characters `brush.new` and `brush.options` take for a new bristle brush name.
pub const MAX_NAME_CHARS: usize = 31;
}
impl Default for Bristle {
@@ -453,6 +458,31 @@ impl Brush {
}
}
/// A brush definition from JSON (a stored library entry or a command's params), its values
/// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key`
/// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or
/// black when they use no solid color.
pub fn from_json(mut v: serde_json::Value) -> Result<Brush, serde_json::Error> {
let keyless = v.get("colorization").is_some_and(|c| {
c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null)
});
if keyless {
// Read the art with the colorization field left out.
let mut plain = v.clone();
if let Some(o) = plain.as_object_mut() {
o.remove("colorization");
}
let b: Brush = serde_json::from_value(plain)?;
let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK);
if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) {
c.insert("key".into(), serde_json::to_value(key)?);
}
}
let mut b: Brush = serde_json::from_value(v)?;
b.sanitize();
Ok(b)
}
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
@@ -514,14 +544,9 @@ pub fn library(doc: &Document) -> Vec<Brush> {
}
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges — see [`Brush::sanitize`]).
/// ranges; see [`Brush::from_json`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
let parse = |b: &serde_json::Value| {
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
b.sanitize();
Some(b)
};
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
+114
View File
@@ -152,6 +152,28 @@ fn art_brush_between_guides_keeps_ends() {
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
}
#[test]
fn art_brush_overlap_splits_at_corners() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((100.0, 0.0));
bp.line_to((100.0, 100.0));
let brush = |overlap: bool| Brush {
name: "a".into(),
kind: BrushKind::Art(ArtBrush {
art: rect_art(10.0, 4.0),
scale: ArtScale::Stretch,
overlap,
colorization: Colorization::None,
..Default::default()
}),
};
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush(false), &bp, &stroke(1.0));
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush(true), &bp, &stroke(1.0));
assert_eq!(one.len(), 1, "one bent copy along the whole path");
assert_eq!(two.len(), 2, "one copy per run between corners");
}
#[test]
fn art_brush_follows_curves() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
@@ -286,6 +308,63 @@ fn tints_colorization_uses_stroke_colour() {
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
fn type_art(fill: Paint, stroke: Paint) -> Node {
use vectorcraft_doc::{CharStyle, TextObject};
let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() };
Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style))))
}
/// The characters' fills and strokes of every piece of type in `nodes`.
fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> {
let mut out = vec![];
for n in nodes {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())));
}
});
}
out
}
/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths.
#[test]
fn scatter_colorization_recolors_type() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0);
let mut path = rect_art(10.0, 4.0);
path.appearance.set_fill(red.clone());
let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]);
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
let path_fill = out[0].children().unwrap()[0].appearance.fill_paint();
assert_ne!(path_fill, red);
let paints = run_paints(&out);
assert_eq!(paints.len(), out.len());
assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}");
// Black type takes the stroke color itself.
let black = Paint::solid(Color::BLACK);
let b = Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone())));
}
/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills.
#[test]
fn stain_mixes_type_strokes_too() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut art = type_art(red.clone(), red);
tint_node(&mut art, &blue, 1.0);
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
}
#[test]
fn library_defaults_store_and_find() {
let mut d = Document::new(100.0, 100.0);
@@ -535,11 +614,46 @@ fn art_colors_list_the_most_used_first() {
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
// Type counts its characters' fills and drawn strokes.
let text = type_art(Paint::solid(red), Paint::solid(red));
let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]);
assert_eq!(art_colors([&g]), vec![red, blue]);
let NodeKind::Text(mut t) = text.kind else { panic!("not type") };
t.runs[0].style.stroke_width = 0.0;
let text = Node::new(NodeId(0), NodeKind::Text(t));
let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted");
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
/// A Hue Shift definition without a key color takes the art's most used color, or black when the
/// art has no solid color; a key that is given is kept.
#[test]
fn hue_shift_without_a_key_takes_the_most_used_art_color() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0));
let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]);
let lib = parse_library(&serde_json::json!([
{"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}},
{"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}},
{"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}},
{"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}},
]));
let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect();
assert_eq!(
keys,
vec![
("s", Colorization::HueShift { key: blue }),
("a", Colorization::HueShift { key: blue }),
("p", Colorization::HueShift { key: red }),
("n", Colorization::HueShift { key: Color::BLACK }),
]
);
}
/// 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]
+51 -21
View File
@@ -154,35 +154,65 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
}
/// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp
/// corner (the whole path when it has none).
fn art_runs(t: &Track) -> Vec<(f64, f64)> {
let l = t.len();
let corners = t.corner_positions();
if corners.is_empty() {
return vec![(0.0, l)];
}
if t.closed {
let first = corners[0].1;
let mut bounds = corners.iter().map(|c| c.1).collect::<Vec<_>>();
bounds.push(first + l);
return bounds.windows(2).map(|w| (w[0], w[1])).collect();
}
let mut bounds = vec![0.0];
bounds.extend(corners.iter().map(|c| c.1));
bounds.push(l);
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
}
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * a.width / 100.0)
}
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
let fixed = (g0 + (w - g1)) * cross;
if fixed >= l || g1 - g0 < 1e-9 {
(AlongMap::linear(w, s0, s1), cross)
} else {
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross)
}
}
};
bend(t, art, b, along, cross)
}
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
let cross = weight * a.width / 100.0;
let mut out = vec![];
for t in tracks(bp, 0.05) {
let l = t.len();
if l <= 1e-9 {
if t.len() <= 1e-9 {
continue;
}
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, 0.0, l), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, 0.0, l), k * a.width / 100.0)
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] };
for (s0, s1) in runs {
if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) {
out.push(n);
}
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
let fixed = (g0 + (w - g1)) * cross;
if fixed >= l || g1 - g0 < 1e-9 {
(AlongMap::linear(w, 0.0, l), cross)
} else {
(AlongMap { xs: [0.0, g0, g1, w], ss: [0.0, g0 * cross, l - (w - g1) * cross, l] }, cross)
}
}
};
if let Some(n) = bend(&t, &art, b, along, cross) {
out.push(n);
}
}
out
+151 -1
View File
@@ -56,6 +56,84 @@ struct Sub {
closed: bool,
}
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
/// relative tolerance rather than exact control-point tests.
struct CircularSub {
centre: Point,
radius: f64,
samples: Vec<Point>,
}
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
let u = 1.0 - t;
let a = u * u * u;
let b = 3.0 * u * u * t;
let c = 3.0 * u * t * t;
let d = t * t * t;
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
}
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
return None;
}
let mut samples = vec![sub.start];
let mut from = sub.start;
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
for i in 1..=8 {
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
}
from = to;
}
if samples.len() < 4 {
return None;
}
let mut anchors = vec![sub.start];
anchors.extend(sub.segs.iter().map(|s| s.end()));
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
anchors.pop();
}
let (p0, p1, p2) = if anchors.len() >= 3 {
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
} else {
(samples[0], samples[samples.len() / 2], *samples.last()?)
};
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
return None;
}
let q0 = p0.x * p0.x + p0.y * p0.y;
let q1 = p1.x * p1.x + p1.y * p1.y;
let q2 = p2.x * p2.x + p2.y * p2.y;
let centre = Point::new(
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
);
let radius = centre.distance(p0);
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
return None;
}
let tolerance = (radius * 5e-4).max(1e-6);
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
return None;
}
Some(CircularSub { centre, radius, samples })
}
fn angle_deg(p: Point, centre: Point) -> f64 {
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
}
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
}
/// The subpaths of `bp` (quadratics raised to cubics).
fn subpaths(bp: &BezPath) -> Vec<Sub> {
let mut out = vec![];
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
pts.pop();
}
self.polyline(&pts, sub.closed, style, width);
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
self.e.entity("SEQEND", end, h, &layer, style, "");
}
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
let geometrically_closed = last.distance(sub.start) <= 1e-9;
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
// before fitting, updating extents or allocating an entity handle.
if sub.closed != geometrically_closed {
return false;
}
let Some(circle) = circular_subpath(sub) else { return false };
let centre = self.to_dxf * circle.centre;
let radius = circle.radius * self.k.abs();
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
return false;
}
// Count sampled points in the drawing extents. For a full circle include all cardinal
// points exactly; for an arc, the dense samples conservatively cover its visible span.
if sub.closed {
let r = circle.radius;
for p in [
Point::new(circle.centre.x - r, circle.centre.y),
Point::new(circle.centre.x + r, circle.centre.y),
Point::new(circle.centre.x, circle.centre.y - r),
Point::new(circle.centre.x, circle.centre.y + r),
] {
self.pt(p);
}
} else {
for p in &circle.samples {
self.pt(*p);
}
}
let layer = self.layer.clone();
let h = self.handle();
if sub.closed {
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
self.e.xyz(10, centre.x, centre.y);
self.e.num(40, radius);
self.e.normal();
return true;
}
let start = self.to_dxf * sub.start;
let end = self.to_dxf * last;
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
let mut a0 = angle_deg(start, centre);
let mut a1 = angle_deg(end, centre);
let am = angle_deg(middle, centre);
if !on_ccw_arc(a0, a1, am) {
std::mem::swap(&mut a0, &mut a1);
}
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
self.e.xyz(10, centre.x, centre.y);
self.e.num(40, radius);
self.e.normal();
if self.o.version.handles() {
self.e.raw(100, "AcDbArc");
}
self.e.num(50, a0);
self.e.num(51, a1);
true
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
@@ -713,7 +862,8 @@ impl<'a> Scene<'a> {
let Some(rgb) = self.paint_rgb(paint) else { continue };
let style = self.style(rgb, 1.0, None, None);
let text_style = self.text_style(&cs.font_family);
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
// The height is that of the size the layout draws: Shrink Text to Fit scales the size by `layout.fit_scale`.
let height = cs.size * layout.fit_scale * cs.v_scale / 100.0 * CAP_HEIGHT * across;
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
let p = self.pt(t.xf * origin);
+94 -14
View File
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(
kinds(&out),
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
"the fill's outline and hatch, then the stroke, then the circle's stroke"
);
let e = entities(&out);
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
let hatch = &e[1];
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
// The ellipse: four cubics through clamped knots.
let spline = &e[3];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// The circular ellipse is preserved as a native circle (#1299).
let circle = &e[3];
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
// R13 has splines and hatches, and polylines of vertices.
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
// R12: polylines only; the fill is its outline, the ellipse flattened.
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let k = kinds(&r12);
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
}
#[test]
fn circles_and_circular_arcs_are_native_dxf_entities() {
let d = doc_with(|d| {
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
let c = Point::new(120.0, 50.0);
let r = 30.0;
let k = r * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + r, c.y));
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
vec![
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
}
// An actual ellipse is not close enough to a circle and remains a spline.
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
}
#[test]
fn r12_stroked_circles_keep_polyline_width() {
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(kind(&e[0]), "POLYLINE");
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
}
#[test]
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
let d = doc_with(|d| {
let c = Point::new(50.0, 50.0);
let r = 30.0;
let k = r * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + r, c.y));
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
arc.close_path();
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let modern = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&modern), ["SPLINE"]);
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// Preserve Appearance outlines only the strokes a line can't draw.
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
st.align = vectorcraft_doc::StrokeAlign::Outside;
}
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
}
@@ -397,6 +456,27 @@ fn type_is_text_or_outlines() {
}
}
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
#[test]
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
let mut d = text_doc("Plan A, plan B and plan C", 20.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut fit = 1.0;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 30.0)) };
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
fit = lay.fit_scale;
}
let out = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&out);
assert!(!e.is_empty() && e.iter().all(|e| kind(e) == "TEXT"), "{:?}", kinds(&out));
for e in &e {
assert!((all(e, 40)[0] - 14.0 * fit).abs() < 1e-6, "the cap height of {} pt type: {:?}", 20.0 * fit, all(e, 40));
}
}
#[test]
fn text_outside_ascii_is_escaped_before_2007() {
let d = text_doc("Größe", 12.0);
@@ -451,7 +531,7 @@ fn crop_leaves_out_art_outside_the_region() {
let d = shapes_doc();
// Only the ellipse (120..180) reaches into 115..200.
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
+19 -2
View File
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
const A2B_GRID: usize = 9;
const B2A_GRID: usize = 17;
/// Grid points per Lab channel of the gamut table.
const GAMUT_GRID: usize = 9;
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
/// TIFF that embedded one, #2707).
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
}
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
for i in 0..GAMUT_GRID.pow(3) {
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
}
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
let mut grid_points = [0u8; 16];
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
num_output_channels: outputs as u8,
grid_points,
clut: Some(LutStore::Store16(clut)),
// A curves sit on the device side, B curves on the PCS side.
a_curves: identity(4),
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
// PCS side.
a_curves: identity(inputs + outputs - 3),
b_curves: identity(3),
m_curves: vec![],
matrix: Matrix3d::IDENTITY,
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
};
let a2b = table(4, 3, A2B_GRID, a2b);
let b2a = table(3, 4, B2A_GRID, b2a);
let gamut = table(3, 1, GAMUT_GRID, gamut);
let mut p = ColorProfile::default();
p.profile_class = ProfileClass::OutputDevice;
p.color_space = DataColorSpace::Cmyk;
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
p.media_white_point = Some(p.white_point);
p.lut_a_to_b_perceptual = Some(a2b.clone());
p.lut_a_to_b_saturation = Some(a2b.clone());
p.lut_a_to_b_colorimetric = Some(a2b);
p.lut_b_to_a_perceptual = Some(b2a.clone());
p.lut_b_to_a_saturation = Some(b2a.clone());
p.lut_b_to_a_colorimetric = Some(b2a);
p.gamut = Some(gamut);
labelled(p, name)
}
+21 -4
View File
@@ -113,11 +113,11 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
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.
/// #1584: every built-in profile (RGB, CMYK and gray) has each tag on a 4-byte boundary and a size
/// that is a multiple of 4 (ICC.1 §7.1.2), and still reads 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] {
fn builtin_profiles_align_their_sizes_and_tags() {
for name in BUILTIN_RGB.into_iter().chain(BUILTIN_CMYK).chain([GRAY]) {
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");
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
}
}
}
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
/// then can't show a CMYK TIFF that embeds it.
#[test]
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
for name in BUILTIN_CMYK {
let icc = 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;
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
}
let p = parse(&icc);
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
}
}
+97 -8
View File
@@ -11,6 +11,7 @@
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
use serde::{Deserialize, Serialize};
use crate::cms::{self, Model};
use crate::{Color, Gradient, GradientStop};
/// The spread a new point gets.
@@ -157,11 +158,12 @@ impl Freeform {
f
}
/// Colour the points along `g`, first to last.
/// Color the points along `g`, first to last, mixing neighboring stops in the model they share
/// ([`Color::lerp_keep_model`]).
pub fn recolor(&mut self, g: &Gradient) {
let n = self.points.len();
for (k, p) in self.points.iter_mut().enumerate() {
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
(p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model);
}
}
@@ -230,10 +232,10 @@ impl Freeform {
best
}
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
/// The color and opacity at `p` for a painted box whose [`spread_scale`] is `scale`, as
/// [`Self::model_field`] mixes them.
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
let ([r, g, b], a) = self.field(scale).sample(p);
(Color::rgb(r, g, b), a)
self.model_field(scale).sample(p)
}
/// The colour field, with colours converted and lines tessellated once (for sampling many
@@ -265,6 +267,32 @@ impl Freeform {
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
}
/// [`Self::field`] mixing the colors in the model every point shares instead of display RGB:
/// the C, M, Y and K inks (as a CMYK document drawn by ink planes mixes them), the Gray ink,
/// or the RGB or Lab components. Points in different models mix in display RGB, as
/// [`Self::field`] mixes them, and the field gives that color in the working RGB space.
pub fn model_field(&self, scale: f64) -> ModelField {
let Some(model) = self.shared_model() else { return ModelField { model: None, first: self.field(scale), k: None } };
let parts = |c: &Color| match *c {
Color::Rgb { r, g, b } => [r, g, b, 0.0],
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
Color::Lab { l, a, b } => [l, a, b, 0.0],
};
let first = self.field_with(scale, &|c| {
let [x, y, z, _] = parts(c);
[x, y, z]
});
let k = (model == Model::Cmyk).then(|| self.field_with(scale, &|c| [parts(c)[3], 0.0, 0.0]));
ModelField { model: Some(model), first, k }
}
/// The color model every point is in (`None` without points or when their models differ).
pub fn shared_model(&self) -> Option<Model> {
let m = self.points.first()?.color.model();
self.points.iter().all(|p| p.color.model() == m).then_some(m)
}
/// Add a point; returns its index.
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
self.points.push(p);
@@ -324,8 +352,9 @@ impl Freeform {
true
}
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
/// opacity and spread between the segment's ends. Returns the new point's index.
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the color
/// (in the model the ends share, [`Color::lerp_keep_model`]), opacity and spread between the
/// segment's ends. Returns the new point's index.
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
let c = *self.segments(line).get(segment)?;
let l = &self.lines[line];
@@ -334,7 +363,7 @@ impl Freeform {
let u = t as f32;
let p = FreeformPoint {
at: c.eval(t),
color: a.color.lerp(&b.color, u),
color: a.color.lerp_keep_model(&b.color, u),
opacity: a.opacity + (b.opacity - a.opacity) * u,
spread: a.spread + (b.spread - a.spread) * u,
};
@@ -440,6 +469,41 @@ impl Field {
}
}
/// A freeform gradient ready to sample in its points' color model ([`Freeform::model_field`]).
#[derive(Clone, Debug)]
pub struct ModelField {
/// The model the points share (`None` when their models differ).
model: Option<Model>,
/// The first three components (C, M and Y; R, G and B; L, a and b; display RGB when the
/// models differ), or the Gray ink as the first of the three values.
first: Field,
/// The K ink of a CMYK field.
k: Option<Field>,
}
impl ModelField {
/// The model the points share (`None` when their models differ).
pub fn model(&self) -> Option<Model> {
self.model
}
/// The color and opacity at `p`: in the points' shared model, else in the working RGB space.
pub fn sample(&self, p: Point) -> (Color, f32) {
let ([x, y, z], a) = self.first.sample(p);
let c = match self.model {
None => {
let [r, g, b] = cms::active().srgb_to_rgb([x, y, z]);
Color::rgb(r, g, b)
}
Some(Model::Rgb) => Color::rgb(x, y, z),
Some(Model::Cmyk) => Color::cmyk(x, y, z, self.k.as_ref().map_or(0.0, |k| k.sample(p).0[0])),
Some(Model::Gray) => Color::gray(x),
Some(Model::Lab) => Color::lab(x, y, z),
};
(c, a)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -477,6 +541,31 @@ mod tests {
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn the_sample_mixes_in_the_model_the_points_share() {
let halfway = |a: Color, b: Color| {
let mut f = two(0.0);
(f.points[0].color, f.points[1].color) = (a, b);
f.sample(Point::new(50.0, 0.0), 50.0).0
};
let near = |a: Color, b: Color| match (a, b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => {
[c - c2, m - m2, y - y2, k - k2].iter().all(|d| d.abs() < 1e-5)
}
(Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() < 1e-5,
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|d| d.abs() < 1e-4),
_ => false,
};
let c = halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
assert!(near(c, Color::cmyk(0.5, 0.0, 0.0, 0.5)), "{c:?}");
let c = halfway(Color::gray(0.2), Color::gray(0.6));
assert!(near(c, Color::gray(0.4)), "{c:?}");
let c = halfway(Color::lab(50.0, 10.0, -20.0), Color::lab(70.0, 30.0, 0.0));
assert!(near(c, Color::lab(60.0, 20.0, -10.0)), "{c:?}");
// Points in different models mix in RGB.
assert!(matches!(halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), red()), Color::Rgb { .. }));
}
#[test]
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
// Scale 100: a 0.2 spread is a 20 pt disc.
+11
View File
@@ -241,6 +241,17 @@ impl Color {
let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
}
/// Linear interpolation in the model both colors share when it is CMYK, Gray or Lab, else in
/// display RGB ([`Color::lerp`]).
pub fn lerp_keep_model(&self, other: &Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (*self, *other) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => self.lerp(other, t),
}
}
/// The colour model this colour is authored in.
pub fn model(&self) -> cms::Model {
match self {
+38 -2
View File
@@ -188,7 +188,7 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
Paint::Solid { color, .. } => css_color(color, 1.0),
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
let stops: Vec<String> =
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect();
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect();
match g.gradient.kind {
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
@@ -371,7 +371,9 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
continue;
}
if let Some(n) = line.strip_prefix("Name:") {
lib.name = n.trim().to_string();
if !n.trim().is_empty() {
lib.name = n.trim().to_string();
}
continue;
}
// Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped.
@@ -648,6 +650,14 @@ mod tests {
use crate::{Gradient, GradientPaint, GradientStop};
use proptest::prelude::*;
#[test]
fn gpl_blank_name_keeps_fallback() {
let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n";
assert_eq!(read(blank, "fallback").unwrap().name, "fallback");
let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n";
assert_eq!(read(named, "fallback").unwrap().name, "Warm");
}
/// A color book's string: a `u32` count of UTF-16 code units, then the units.
fn acb_str(out: &mut Vec<u8>, s: &str) {
let units: Vec<u16> = s.encode_utf16().collect();
@@ -805,6 +815,32 @@ mod tests {
assert!(read(&text, "x").is_err(), "CSS is written only");
}
#[test]
fn css_preserves_fractional_gradient_stop_positions() {
for kind in [GradientKind::Linear, GradientKind::Radial] {
let lib = SwatchLibrary {
name: "Positions".into(),
swatches: vec![Swatch {
name: "Ramp".into(),
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient {
kind,
stops: vec![
GradientStop::new(0.0, Color::BLACK),
GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
GradientStop::new(0.12890625, Color::WHITE),
GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
],
}))),
global: false,
spot: false,
}],
groups: vec![],
};
let text = written(&lib, PaletteFormat::Css);
assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}");
}
}
#[test]
fn holds_counts_the_swatches_each_format_writes() {
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
+34 -3
View File
@@ -264,6 +264,8 @@ pub struct RowMap {
mean_l: f32,
/// Hue turn from the key colour to the new colour (Hue Shift).
turn: f32,
/// Whether colours take the new colour in its own model, not in the model they had.
own_model: bool,
}
impl RowMap {
@@ -274,7 +276,20 @@ impl RowMap {
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
_ => 0.0,
};
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0], own_model: false }
}
/// With `own`, current colours take the new colour in the new colour's model (an RGB colour
/// replaced by a CMYK one becomes CMYK) instead of keeping the model they had.
pub fn with_own_model(mut self, own: bool) -> Self {
self.own_model = own;
self
}
/// True when current colour `c` keeps a colour model other than the new colour's (so the new
/// colour is converted into `c`'s model and may look different from what was asked for).
pub fn converts(&self, c: Color) -> bool {
!self.own_model && c.model() != self.to.model()
}
/// The colour of the row the new colour stands for.
@@ -301,7 +316,7 @@ impl RowMap {
/// 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);
let to = if self.own_model { self.to } else { keep_model(c, self.to) };
match self.method {
Method::Exact => to,
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
@@ -314,7 +329,13 @@ impl RowMap {
Method::HueShift if c == self.key => to,
Method::HueShift => {
let [h, s, v] = c.to_hsb();
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
if s <= 0.0 {
if self.own_model { c.in_model(self.to.model()) } else { c }
} else if self.own_model {
Color::from_hsb(h + self.turn, s, v).in_model(self.to.model())
} else {
keep_model(c, Color::from_hsb(h + self.turn, s, v))
}
}
}
}
@@ -576,6 +597,16 @@ mod tests {
assert!(cluster(&[], &[], 3).is_empty());
}
#[test]
fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() {
let to = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let row = [Color::rgb(0.2, 0.2, 0.9), Color::rgb(0.1, 0.1, 0.5), Color::rgb(0.3, 0.3, 0.3)];
let own = RowMap::new(Method::HueShift, &row, to).with_own_model(true);
assert!(row.iter().all(|c| own.map(*c).model() == to.model()), "key and other colours alike");
let kept = RowMap::new(Method::HueShift, &row, to);
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
}
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
+4 -3
View File
@@ -739,9 +739,10 @@ pub struct Appearance {
pub items: Vec<AppearanceItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>,
/// Groups, layers and type: how many items paint below the object's contents (its members, or
/// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`:
/// every item paints above the contents (see [`Self::contents_at`]).
/// Groups, layers, type and images: how many items paint below the object's contents (its
/// members, type's characters, or an image's pixels), the slot of the Appearance panel's
/// Contents (Characters) row. `None`: every item paints above the contents (see
/// [`Self::contents_at`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contents_index: Option<usize>,
}
+105 -17
View File
@@ -33,13 +33,7 @@ use crate::text::TextObject;
/// Interpolate two colours in their shared model (display RGB when the models differ).
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (*a, *b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => a.lerp(b, t),
}
a.lerp_keep_model(b, t)
}
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
@@ -50,15 +44,45 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
x + (y - x) * t
}
fn solid_gradient(c: Color, like: &Gradient) -> Gradient {
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`).
fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient {
let mut g = like.clone();
for s in &mut g.stops {
s.color = c;
s.opacity = 1.0;
s.swatch = swatch.map(str::to_string);
s.tint = tint;
}
g
}
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
let stops = &g.stops;
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
let Some(first) = stops.first() else { return (None, 1.0) };
if t <= first.offset {
return link(first);
}
for w in stops.windows(2) {
let (a, b) = (&w[0], &w[1]);
if t <= b.offset {
let span = (b.offset - a.offset).max(1e-6);
let u = (t - a.offset) / span;
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return match (&a.swatch, &b.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)),
_ if v <= 0.0 => link(a),
_ if v >= 1.0 => link(b),
_ => (None, 1.0),
};
}
}
stops.last().map_or((None, 1.0), link)
}
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
let mut out = g.clone();
@@ -66,7 +90,8 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
.iter()
.map(|&o| {
let (color, opacity) = g.sample_with(o, lerp_color);
GradientStop { opacity, ..GradientStop::new(o, color) }
let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
})
.collect();
out
@@ -112,10 +137,19 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
.stops
.iter()
.zip(&gb.stops)
.map(|(sa, sb)| GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
.map(|(sa, sb)| {
// Stops on two tints of one swatch stay linked to it, as solids do (#3234).
let (swatch, tint) = match (&sa.swatch, &sb.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)),
_ => (None, 1.0),
};
GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
swatch,
tint,
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
}
})
.collect();
out.angle = a.angle + (b.angle - a.angle) * t as f64;
@@ -137,17 +171,21 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
/// solid↔gradient; otherwise switch halfway.
pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
match (a, b) {
// Two tints of one swatch blend into tints of that swatch (a spot stays a spot on export).
(Paint::Solid { color: x, swatch: Some(sa), tint: ta }, Paint::Solid { color: y, swatch: Some(sb), tint: tb }) if sa == sb => {
Paint::Solid { color: lerp_color(x, y, t), swatch: Some(sa.clone()), tint: lerp_f32(*ta, *tb, t) }
}
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)),
(Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) {
Some(g) => Paint::Gradient(Box::new(g)),
None => (if t < 0.5 { a } else { b }).clone(),
},
(Paint::Solid { color, .. }, Paint::Gradient(g)) => {
let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
(Paint::Solid { color, swatch, tint }, Paint::Gradient(g)) => {
let ga = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone())))
}
(Paint::Gradient(g), Paint::Solid { color, .. }) => {
let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
(Paint::Gradient(g), Paint::Solid { color, swatch, tint }) => {
let gb = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone())))
}
_ => (if t < 0.5 { a } else { b }).clone(),
@@ -1120,6 +1158,56 @@ mod tests {
assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up");
}
#[test]
fn blend_between_tints_of_one_swatch_keeps_the_link() {
let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint };
let Paint::Solid { swatch, tint, .. } = lerp_paint(&tinted(0.2), &tinted(1.0), 0.5) else { panic!() };
assert_eq!(swatch.as_deref(), Some("Spot"));
assert!((tint - 0.6).abs() < 1e-6, "{tint}");
let other = Paint::Solid { color: Color::BLACK, swatch: Some("Other".into()), tint: 1.0 };
assert!(matches!(lerp_paint(&tinted(0.2), &other, 0.5), Paint::Solid { swatch: None, .. }));
// Gradient stops on tints of one swatch stay linked too; a process solid unlinks them.
let spot_gradient = |tint: f32| {
let mut stop = GradientStop::new(0.0, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
stop.swatch = Some("Spot".into());
stop.tint = tint;
let stops = vec![stop.clone(), GradientStop { offset: 1.0, ..stop }];
Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })))
};
let Paint::Gradient(m) = lerp_paint(&spot_gradient(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
let Paint::Gradient(m) = lerp_paint(&tinted(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &spot_gradient(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn resampled_gradient_stops_keep_the_swatch_link() {
let spot = |offset: f32, tint: f32| {
let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
stop.swatch = Some("Spot".into());
stop.tint = tint;
stop
};
let paint = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })));
let two = paint(vec![spot(0.0, 0.2), spot(1.0, 1.0)]);
let three = paint(vec![spot(0.0, 0.2), spot(0.5, 0.6), spot(1.0, 1.0)]);
let Paint::Gradient(m) = lerp_paint(&two, &three, 0.5) else { panic!() };
assert_eq!(m.gradient.stops.len(), 3);
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
let tints: Vec<f32> = m.gradient.stops.iter().map(|s| s.tint).collect();
assert!((tints[1] - 0.6).abs() < 1e-6 && (tints[2] - 1.0).abs() < 1e-6, "{tints:?}");
// Spot on one side, process on the other: the stops are unlinked.
let mut plain = spot(0.5, 1.0);
plain.swatch = None;
let mixed = paint(vec![spot(0.0, 1.0), plain, spot(1.0, 1.0)]);
let Paint::Gradient(m) = lerp_paint(&two, &mixed, 0.5) else { panic!() };
assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn gradients_with_different_stop_counts_interpolate() {
let g = |stops: &[(f32, Color)]| {
+179 -34
View File
@@ -7,11 +7,68 @@ use std::collections::BTreeSet;
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Rect};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::node::{LiveShape, Node, NodeKind};
use crate::selection::AnchorRef;
/// Stored corner radii as document lengths for cutting and display (fractions when `relative`).
pub fn resolve_stored_radii(base: &PathData, stored: &[f64], relative: bool) -> Vec<f64> {
if !relative {
return stored.to_vec();
}
let corners = path_corners(base);
let mut out = stored.to_vec();
out.resize(base.anchor_count(), 0.0);
for c in corners {
if let Some(r) = out.get_mut(c.index) {
*r = (*r).clamp(0.0, 1.0) * c.max_radius();
}
}
out
}
/// Turn stored absolute radii into fractions of each corner's maximum, or the other way.
pub fn convert_radii_relative(live: &mut LiveShape, relative: bool) {
if live.radii_relative() == relative {
return;
}
let was_relative = live.radii_relative();
let base = match live {
LiveShape::Rectangle { w, h, .. } => shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)),
LiveShape::Polygon { .. } => live.polygon_outline(),
LiveShape::Path { base, .. } => base.clone(),
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
};
let corners = path_corners(&base);
let radii: &mut [f64] = match live {
LiveShape::Rectangle { radii, .. } => radii,
LiveShape::Polygon { radii, .. } => radii,
LiveShape::Path { radii, .. } => radii,
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
};
for c in corners {
if let Some(r) = radii.get(c.index) {
let max = c.max_radius();
radii[c.index] = if relative {
if max > 1e-12 && !was_relative { (*r / max).clamp(0.0, 1.0) } else { *r }
} else if was_relative {
(*r).clamp(0.0, 1.0) * max
} else {
*r
};
}
}
match live {
LiveShape::Rectangle { radii_relative: rel, .. }
| LiveShape::Polygon { radii_relative: rel, .. }
| LiveShape::Path { radii_relative: rel, .. } => {
*rel = relative;
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => {}
}
}
/// The Live Corners of a path.
#[derive(Clone, Debug)]
pub struct LiveCorners<'a> {
@@ -26,6 +83,8 @@ pub struct LiveCorners<'a> {
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
drawn: Vec<f64>,
kinds: Vec<CornerKind>,
/// Stored radii are fractions of each corner's maximum, not document lengths.
relative: bool,
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
sources: Vec<Vec<usize>>,
}
@@ -34,16 +93,21 @@ impl<'a> LiveCorners<'a> {
/// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses,
/// lines).
pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option<Self> {
let (base, xf, radii, kinds) = match live {
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]),
let (base, xf, stored, kinds, relative) = match live {
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![], false),
Some(l @ LiveShape::Rectangle { .. }) => {
let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None };
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec())
let LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } = l.folded() else { return None };
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec(), radii_relative)
}
Some(l @ LiveShape::Polygon { radii, kinds, radii_relative, .. }) => {
(Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative)
}
Some(LiveShape::Path { base, radii, kinds, radii_relative }) => {
(Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative)
}
Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()),
Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()),
Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None,
};
let radii = resolve_stored_radii(&base, &stored, relative);
let sources = if radii.iter().any(|r| *r > 0.0) {
cut_corners(&base, &radii, &kinds).1
} else {
@@ -58,7 +122,7 @@ impl<'a> LiveCorners<'a> {
};
let corners = path_corners(&base);
let drawn = fitted_radii(&corners, &radii);
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
Some(Self { corners, base, xf, radii: stored, drawn, kinds, relative, sources })
}
/// The Live Corners of a path object.
@@ -72,7 +136,13 @@ impl<'a> LiveCorners<'a> {
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i))
}
/// The radius corner `index` is set to, in document units (drawn no larger than fits).
/// Whether corner radii are stored as fractions of each corner's maximum.
pub fn radii_relative(&self) -> bool {
self.relative
}
/// The radius corner `index` is stored at: document lengths, or a fraction in 0..1 when
/// [`LiveCorners::radii_relative`].
pub fn radius(&self, index: usize) -> f64 {
self.radii.get(index).copied().unwrap_or(0.0)
}
@@ -119,8 +189,9 @@ impl<'a> LiveCorners<'a> {
&self.sources
}
/// The radius (document units) and kind `corners` share (each `None` when they differ, or
/// for no corners).
/// The radius and kind `corners` share (each `None` when they differ, or for no corners).
/// Radius is what was set ([`LiveCorners::radius`]: document lengths, or a fraction in 0..1
/// when [`LiveCorners::radii_relative`]), not the fitted draw size ([`LiveCorners::drawn_radius`]).
pub fn style(&self, corners: &BTreeSet<usize>) -> (Option<f64>, Option<CornerKind>) {
fn shared<T: Copy>(mut it: impl Iterator<Item = T>, same: impl Fn(T, T) -> bool) -> Option<T> {
let first = it.next()?;
@@ -137,29 +208,31 @@ impl<'a> LiveCorners<'a> {
/// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they
/// are.
pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(
base: &PathData,
relative: bool,
radii: &mut [f64],
kinds: &mut [CornerKind],
corners: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
let corners_on_base = path_corners(base);
for k in corners {
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
*slot = r.max(0.0);
*slot = if relative {
let max = corners_on_base.iter().find(|c| c.index == *k).map(|c| c.max_radius()).unwrap_or(0.0);
if max > 1e-12 { (r / max).clamp(0.0, 1.0) } else { 0.0 }
} else {
r.max(0.0)
};
}
if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) {
*slot = kind;
}
}
}
/// One radius and kind per corner of an outline of `n` anchors while editing; none saved
/// when all are sharp or round.
fn edit_vec(
n: usize,
radii: &mut Vec<f64>,
kinds: &mut Vec<CornerKind>,
corners: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(radii, kinds, corners, radius, kind);
fn trim_vec_radii(radii: &mut Vec<f64>, kinds: &mut Vec<CornerKind>) {
if radii.iter().all(|r| *r <= 0.0) {
radii.clear();
}
@@ -167,18 +240,35 @@ pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corne
kinds.clear();
}
}
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }));
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![], radii_relative: false }));
// Radii are document lengths and corners circular, also on a rectangle from a file that kept
// an uneven scale in its transform (#442).
l.fold_scale();
let relative = l.radii_relative();
match &mut **l {
LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind),
LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind),
LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind),
LiveShape::Rectangle { w, h, radii, kinds, .. } => {
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
edit(&base, relative, radii, kinds, corners, radius, kind);
}
LiveShape::Polygon { radius: poly_r, sides, xf, radii, kinds, .. } => {
let outline = shapes::polygon(Point::ZERO, *poly_r, *sides, 0.0).transformed(*xf);
let n = *sides as usize;
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(&outline, relative, radii, kinds, corners, radius, kind);
trim_vec_radii(radii, kinds);
}
LiveShape::Path { base, radii, kinds, .. } => {
let n = base.anchor_count();
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(base, relative, radii, kinds, corners, radius, kind);
trim_vec_radii(radii, kinds);
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
}
match live.as_deref_mut() {
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
Some(LiveShape::Path { base, radii, kinds, .. }) if radii.is_empty() && kinds.is_empty() => {
*path = std::mem::take(base);
*live = None;
}
@@ -202,7 +292,14 @@ mod tests {
#[test]
fn rectangle_corners_map_to_their_anchors() {
let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
let l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [0.0, 10.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
let path = l.to_path();
let c = LiveCorners::new(&path, Some(&l)).unwrap();
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
@@ -229,7 +326,7 @@ mod tests {
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
let (mut path, mut live) = (star.clone(), None);
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None);
let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") };
let Some(LiveShape::Path { base, radii, kinds, .. }) = live.as_deref() else { panic!("live corners: {live:?}") };
assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0));
assert_eq!(path.anchor_count(), 12, "two corners cut");
let c = LiveCorners::new(&path, live.as_deref()).unwrap();
@@ -245,7 +342,8 @@ mod tests {
/// A polygon's corners are cut in the document and survive a change of sides when shared.
#[test]
fn polygon_corners_follow_their_sides() {
let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] };
let mut l =
LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![], radii_relative: false };
let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone())));
set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None);
assert_eq!(path.anchor_count(), 12);
@@ -265,6 +363,53 @@ mod tests {
assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts");
}
#[test]
fn relative_radii_scale_with_rectangle_size() {
let l = LiveShape::Rectangle {
w: 100.0,
h: 100.0,
radii: [0.25, 0.25, 0.25, 0.25],
kinds: Default::default(),
radii_relative: true,
xf: Affine::IDENTITY,
};
let path = l.to_path();
let c = LiveCorners::new(&path, Some(&l)).unwrap();
let r0 = c.drawn_radius(0);
let l2 = LiveShape::Rectangle {
w: 200.0,
h: 200.0,
radii: [0.25, 0.25, 0.25, 0.25],
kinds: Default::default(),
radii_relative: true,
xf: Affine::IDENTITY,
};
let path2 = l2.to_path();
let c2 = LiveCorners::new(&path2, Some(&l2)).unwrap();
assert!((c2.drawn_radius(0) / r0 - 2.0).abs() < 0.05, "{r0} → {}", c2.drawn_radius(0));
}
#[test]
fn convert_between_relative_and_absolute_radii() {
let mut l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [10.0, 0.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
let before = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
convert_radii_relative(&mut l, true);
assert!(l.radii_relative());
let mid = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
assert!((mid - before).abs() < 0.5);
convert_radii_relative(&mut l, false);
assert!(!l.radii_relative());
let after = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
assert!((after - before).abs() < 0.5);
}
#[test]
fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() {
let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false));
+328 -25
View File
@@ -1,11 +1,12 @@
//! Hit testing over the document tree.
use std::collections::HashSet;
use std::sync::OnceLock;
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitKind {
@@ -62,14 +63,42 @@ pub struct HitOptions {
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
/// bounds.
pub type_path_only: bool,
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
/// on or the guides are hidden.
pub skip_guides: bool,
/// The selection tools: a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips, so that the mask can be reached where the art covers
/// its edge. Off for the tools that act on what is drawn (sampling, type, gradients…);
/// [`hit_test_visible`] never does it.
pub clip_outlines: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
}
}
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
/// above this crate, so the engine registers one with [`set_image_paint`].
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
/// Register how the shapes of an image's own fills and strokes are found (the first registration
/// stays).
pub fn set_image_paint(f: ImagePaint) {
// Already set: the same engine, registered by another session.
let _ = IMAGE_PAINT.set(f);
}
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
/// nothing is registered.
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false, true)
@@ -110,6 +139,11 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
return objects_at_repeated(doc, p, opt, scope);
}
}
// The objects on a locked artboard can't be selected, as `hit_test_skipping` passes over them.
let locks = doc.locked_artboards();
if !locks.is_empty() {
tops.retain(|id| !doc.node(*id).is_some_and(|n| locks.holds(n)));
}
tops
}
@@ -119,8 +153,16 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
/// 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;
if layer.clips()
&& let Some(clip) = children.first()
{
// As the hit test does: the clipping mask's outline first, then only inside the mask.
if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) {
tops.push(clip.id);
}
if !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() {
@@ -243,14 +285,24 @@ fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> boo
}
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with
/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of
/// an isolated group on one ([`crate::LockedArtboards::holds`]). 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 locks = doc.locked_artboards();
let on_locked = |id: NodeId| {
editable_only
&& !locks.is_empty()
&& doc.parent_of(id).and_then(|l| doc.node(l)).is_some_and(Node::is_layer)
&& doc.node(id).is_some_and(|n| locks.holds(n))
};
let skip = &|id| skip(id) || on_locked(id);
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).
@@ -259,6 +311,10 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
// Isolated before its artboard was locked: nothing in it hits.
if locks.holds_id(scope) {
return None;
}
if isolated.is_container() {
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
}
@@ -318,6 +374,9 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
if !c.visible || editable_only && (c.locked || c.is_template()) {
return false;
}
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
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_of(c, opt.scope)
@@ -336,6 +395,31 @@ fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
/// A press on the outline of clipping path `clip` (a path or a compound path; type clips by its
/// glyphs, which this crate cannot outline), within the tolerance.
fn clip_outline(clip: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
let bp = match &clip.kind {
NodeKind::Path { path, .. } => path.to_bezpath(),
NodeKind::Compound { .. } => clip.stroke_path()?,
_ => return None,
};
stroke_contains(&bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
/// For the selection tools ([`HitOptions::clip_outlines`]), a press on the outline of `clip`, the
/// clipping path of `parent` (a clip group or a layer with a clipping mask), within the tolerance:
/// it is hit before the art it clips. There Direct Selection takes its segments, and the
/// Selection tool the clip group. The reference app reshapes a clipping path with Direct Selection
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>);
/// without this, art drawn over the path's edge is hit first and its segments can't be dragged.
/// The clipping path must be one a click may hit ([`may_hit`]: visible, editable, within reach).
fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> Option<HitKind> {
if !opt.clip_outlines || !editable_only || !may_hit(parent, 0, clip, p, opt, symbols, editable_only) {
return None;
}
clip_outline(clip, p, opt)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -346,12 +430,17 @@ fn hit_children(
editable_only: bool,
) -> Option<Hit> {
let children = parent.children()?;
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
if parent.clips()
&& let Some(clip) = children.first()
&& !clip_contains(clip, p)
{
return None;
if let Some(kind) = clip_outline_hit(parent, clip, p, opt, symbols, editable_only).filter(|_| !skip(clip.id)) {
chain.push(clip.id);
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
}
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
if !clip_contains(clip, p) {
return None;
}
}
for i in hit_order(parent, children, p) {
let Some(c) = children.get(i) else { continue };
@@ -433,12 +522,31 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
NodeKind::Text(_)
| NodeKind::Image(_)
| NodeKind::PlacedDocument(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
// mode.
NodeKind::Image(im) => {
if opt.path_only {
use vectorcraft_geom::kurbo::Shape;
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
frame.apply_affine(im.xf);
if stroke_contains(&frame, 0.0, opt.tol, p) {
return Some(HitKind::Outline);
}
} else if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return Some(HitKind::Bounds);
}
if opt.outline {
return None;
}
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
}
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | 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();
@@ -495,10 +603,11 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
}
/// 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`]). The art of a clip group
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
/// path clips to, and then only within that region's bounds.
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
/// only within that region's bounds.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
@@ -541,13 +650,16 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
out
}
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
/// ([`shaper_hides`]).
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
/// compound paths, layers and groups are touched by their box.
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => marquee_in(n, r)
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)),
// 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| {
@@ -558,7 +670,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
let Some(r) = marquee_in(n, r) else { return };
for (i, c) in children.iter().enumerate() {
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
@@ -574,8 +686,9 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
}
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
for (t, r) in tops {
if !t.visible || t.locked {
if !t.visible || t.locked || locks.holds_id(t.id) {
continue;
}
if leaves {
@@ -822,6 +935,167 @@ mod tests {
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
}
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
/// Selection take, or in isolation mode, it selects the member.
#[test]
fn a_marquee_meets_an_intertwine_group_by_its_members() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
let (a_id, g) = (a.id, d.alloc_id());
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert!(marquee(&d, gap, Some(g), false).is_empty());
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
// This marquee crosses the first square's left edge.
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, edge, None, false), vec![g]);
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
}
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
#[test]
fn a_marquee_meets_a_repeat_by_its_copies() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
// Copies at 0..20, 40..60 and 80..100.
let kind = crate::pattern::RepeatKind::Grid {
h_spacing: 20.0,
v_spacing: 20.0,
rows: 1,
cols: 3,
grid_type: crate::pattern::TileType::Grid,
flip_rows: false,
flip_cols: false,
};
let r = d.alloc_id();
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
}
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
/// too: inside the box, away from every key object's and the content's outline, it takes them.
#[test]
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
let mut d = Document::new(300.0, 300.0);
let l = d.layers[0].id;
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
let blend = d.alloc_id();
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
let envelope = d.alloc_id();
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
let e = Node::new(
envelope,
NodeKind::Envelope {
content,
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: false,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(l), 1, e).unwrap();
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
assert_eq!(marquee(&d, r, None, false), vec![id]);
assert_eq!(marquee(&d, r, None, true), vec![id]);
}
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
if layer {
d.insert(Some(l), 0, path).unwrap();
d.insert(Some(l), 1, picture).unwrap();
d.node_mut(l).unwrap().set_clips(true);
(d, l, clip, art)
} else {
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
(d, g, clip, art)
}
}
const SELECTING: HitOptions =
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
/// For the selection tools a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
#[test]
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
let (d, g, clip, art) = masked_square(false);
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
}
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
assert_eq!((h.leaf, h.top_object(None)), (art, g));
// Beyond the tolerance outside it, the clipped art is not there to hit.
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
}
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
/// nothing there.
#[test]
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
let (d, _, clip, art) = masked_square(false);
let p = Point::new(138.5, 90.0);
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
// A hidden or locked clipping path is passed over.
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
let mut d = d.clone();
change(d.node_mut(clip).unwrap());
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
}
}
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
/// well inside, the art alone (the mask paints nothing there).
#[test]
fn select_behind_takes_a_layer_masks_outline_first() {
let (d, _, clip, art) = masked_square(true);
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
let p = Point::new(x, 90.0);
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
}
// Without clip_outlines, as before.
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
@@ -889,6 +1163,28 @@ mod tests {
}
}
/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so
/// a path under it can be picked through it.
#[test]
fn path_only_picks_paths_through_images() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (path, image) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default() };
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5));
assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path");
assert_eq!(hit(inside, HitOptions::default()), Some(image));
assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image");
assert_eq!(hit(inside, path_only), None, "path only: not inside the image");
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
@@ -1116,9 +1412,16 @@ world",
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
// A clipping mask on the layer, or on a sublayer, now and then.
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
for id in std::iter::once(layer).chain(sublayers) {
if next(3) == 0 {
d.node_mut(id).unwrap().set_clips(true);
}
}
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() };
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
+375 -11
View File
@@ -36,6 +36,7 @@ pub mod marks;
mod mem;
pub mod metadata;
pub mod node;
pub mod objects_on_path;
pub mod orient;
pub mod overprint;
pub mod pattern;
@@ -117,9 +118,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
@@ -215,13 +216,33 @@ impl Unit {
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
pub fn ruler_step(self, zoom: f64) -> f64 {
const STEPS: [f64; 19] =
const DECIMAL: [f64; 19] =
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
// between them grow denser, down to a hundred and twenty-eighth
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
// the left ruler has room for when it spells one down the ruler a character at a time.
// From an inch up the ladder is the shared one.
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
}
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
/// units, points, pixels and picas are read in tenths.
fn ruler_halves(self) -> bool {
matches!(self, Unit::Inches)
}
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
/// as dense as the tenths elsewhere; a tenth of it otherwise.
pub fn ruler_minor(self, step: f64) -> f64 {
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
}
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
let tick = self.ruler_step(zoom) / 10.0 * self.points();
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
(v / tick).round() * tick
}
pub fn from_pt(self, v: f64) -> f64 {
@@ -274,7 +295,12 @@ impl Unit {
pub fn named(name: &str) -> Option<Unit> {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
// is left to its label and key.
Unit::ALL
.into_iter()
.find(|u| letters(u.label()).eq(given()))
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
}
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
@@ -412,10 +438,112 @@ pub struct Artboard {
pub name: String,
/// Artboard rectangle in document points.
pub rect: Rect,
/// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands
/// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be
/// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays
/// selected ([`Document::new_art`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub locked: bool,
#[serde(default)]
pub show_center_mark: bool,
#[serde(default)]
pub show_cross_hairs: bool,
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
/// it out.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
rects: Vec<Rect>,
symbols: SymbolBoxes<'a>,
/// The new art, which isn't on them ([`Document::new_art`]).
new_art: Arc<std::collections::HashSet<NodeId>>,
/// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no
/// artboard.
pattern_art: std::collections::HashSet<NodeId>,
/// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a
/// group are each answered with one measurement of the group.
held: std::cell::RefCell<std::collections::HashMap<NodeId, bool>>,
}
impl LockedArtboards<'_> {
/// No artboard is locked.
pub fn is_empty(&self) -> bool {
self.rects.is_empty()
}
/// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its
/// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An
/// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or
/// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or
/// new art while it stays selected ([`Document::new_art`]).
pub fn holds(&self, n: &Node) -> bool {
!self.new_art.contains(&n.id) && self.overlaps(n)
}
/// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it
/// is new art?
pub fn overlaps(&self, n: &Node) -> bool {
if self.rects.is_empty()
|| !n.visible
|| n.is_layer()
|| matches!(n.kind, NodeKind::Path { guide: true, .. })
|| self.pattern_art.contains(&n.id)
{
return false;
}
if let Some(held) = self.held.borrow().get(&n.id) {
return *held;
}
let held =
n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0));
self.held.borrow_mut().insert(n.id, held);
held
}
/// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id`
/// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])?
pub fn holds_id(&self, id: NodeId) -> bool {
!self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n))
}
}
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
#[derive(Default)]
pub struct NewArtIds(std::sync::RwLock<Arc<std::collections::HashSet<NodeId>>>);
impl NewArtIds {
/// The objects.
pub fn get(&self) -> Arc<std::collections::HashSet<NodeId>> {
self.0.read().map(|g| g.clone()).unwrap_or_default()
}
/// Make `ids` the objects.
pub fn set(&self, ids: Arc<std::collections::HashSet<NodeId>>) {
if let Ok(mut g) = self.0.write() {
*g = ids;
}
}
}
impl Clone for NewArtIds {
fn clone(&self) -> Self {
NewArtIds(std::sync::RwLock::new(self.get()))
}
}
/// Editing state, not document content: two documents are equal whatever new art they name.
impl PartialEq for NewArtIds {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for NewArtIds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("NewArtIds").field(&self.get()).finish()
}
}
/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing.
@@ -769,6 +897,12 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// New art on a locked artboard: objects of layers on a locked artboard that an edit made and
/// left selected (a group with selected members counts), kept while they stay selected and on
/// one. They and their contents can be selected and edited ([`LockedArtboards::holds`]). The
/// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved.
#[serde(skip)]
pub new_art: NewArtIds,
/// 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")]
@@ -801,6 +935,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number).
pub fn raster_ppi(&self) -> f64 {
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
}
/// 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 {
@@ -850,8 +990,10 @@ impl Document {
id: 1,
name: "Artboard 1".into(),
rect: Rect::new(0.0, 0.0, width, height),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
}],
layers: vec![],
swatches: swatches.into(),
@@ -893,6 +1035,7 @@ impl Document {
assets: vec![],
svg_scripts: vec![],
puppet: None,
new_art: Default::default(),
dimensions: false,
index: Default::default(),
};
@@ -1326,9 +1469,69 @@ impl Document {
pub fn node_count(&self) -> usize {
self.layers.iter().map(|l| l.count()).sum()
}
/// Is the node (and every ancestor) visible and unlocked?
/// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard
/// ([`Document::on_locked_artboard`])?
pub fn is_editable(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked)))
self.is_editable_with(&self.locked_artboards(), id)
}
/// [`Document::is_editable`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id)
}
/// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once
/// for all of them, a group once for all its members.
pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ {
let locks = self.locked_artboards();
move |id| self.is_editable_with(&locks, id)
}
/// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]).
pub fn locked_artboards(&self) -> LockedArtboards<'_> {
self.locked_artboards_except(usize::MAX)
}
/// [`Document::locked_artboards`] leaving out artboard `index`.
pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> {
// Art flush with an edge from outside can overlap it by a rounding error, as in
// `art_bounds_on` (#1295).
let rects: Vec<Rect> =
self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect();
// The objects of pattern editing mode's layer, a sublayer's objects included.
fn objects(n: &Node, out: &mut std::collections::HashSet<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_layer() {
objects(c, out);
} else {
out.insert(c.id);
}
}
}
let mut pattern_art = std::collections::HashSet::new();
if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) {
objects(layer, &mut pattern_art);
}
LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() }
}
/// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none.
pub fn on_locked_artboard(&self, id: NodeId) -> bool {
self.locked_artboards().holds_id(id)
}
/// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else
/// the group or other object around it right inside a layer. None for a layer or sublayer.
pub fn layer_object(&self, id: NodeId) -> Option<&Node> {
self.layer_object_at(&self.index_path(id)?)
}
/// [`Document::layer_object`] for the node at index path `path`.
pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> {
// Down the path to the first node that isn't a layer.
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
if !n.is_layer() {
return Some(n.as_ref());
}
nodes = n.children()?;
}
None
}
pub fn is_visible(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
@@ -1339,9 +1542,16 @@ impl Document {
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
}
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first),
/// except those on a locked artboard.
pub fn selectable_art(&self) -> Vec<NodeId> {
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
let locks = self.locked_artboards();
self.layers
.iter()
.filter(|l| l.visible && !l.locked && !l.is_template())
.flat_map(|l| l.layer_art(true))
.filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n)))
.collect()
}
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
@@ -1414,6 +1624,18 @@ impl Document {
}
art
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
/// locked art does.
pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec<NodeId> {
let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] };
let mut art = self.art_on_artboard(rect, locked_and_hidden);
let locks = self.locked_artboards_except(index);
if !locked_and_hidden && !locks.is_empty() {
art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n)));
}
art
}
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
/// artboard is gone.
@@ -1504,6 +1726,7 @@ impl Document {
n += usize::from(*k);
}
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten());
let before = self.guides.len();
let mut flags = kept.into_iter();
self.guides.retain(|_| flags.next().unwrap_or(true));
@@ -1549,9 +1772,17 @@ impl Document {
{
dimension.source = None;
}
let mut renamed = std::collections::HashMap::new();
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();
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
}
// An Objects on Path group names its path and its objects by id: the copy names the copies.
if let Some(e) = n.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.rename(&renamed);
}
n
}
@@ -1792,6 +2023,46 @@ mod tests {
use super::*;
use vectorcraft_geom::shapes;
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
/// are binary fractions now, and the minor ticks between them stay binary too; every other
/// unit is still read in tenths.
#[test]
fn an_inch_ruler_steps_in_binary_fractions() {
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
let s = step(Unit::Inches, zoom);
if s <= 1.0 {
let k = s.recip();
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
}
// A label step always leaves at least 50 px, which is what the ladder is picked for.
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
}
// The steps the report named: tenths became quarters, eighths and sixteenths.
assert_eq!(step(Unit::Inches, 5.0), 0.25);
assert_eq!(step(Unit::Inches, 10.0), 0.125);
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
// An inch and wider is unchanged.
assert_eq!(step(Unit::Inches, 1.0), 1.0);
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
// further only adds ticks. A label therefore never needs more than the six characters of
// "0.0625", which the left ruler has room for when it spells one out a character per line.
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
}
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
// 128ths the report expects to appear unlabelled when zoomed in close.
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
}
fn doc_with_rects() -> (Document, NodeId, NodeId) {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
@@ -1851,6 +2122,99 @@ mod tests {
}
}
/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one
/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with
/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one.
/// Unlocking the artboard gives the art back.
#[test]
fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
use crate::hit::{HitOptions, hit_test, marquee, objects_at};
let (mut d, a, b) = doc_with_rects();
let layer = d.layers[0].id;
d.artboards.push(Artboard {
id: 2,
name: "Artboard 2".into(),
rect: Rect::new(700.0, 0.0, 900.0, 200.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap();
id
};
let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art());
// A group with one member on the artboard and one right of it.
let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let members = [(m1, 750.0), (m2, 950.0)]
.map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art())));
d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap();
// Unstroked and flush with the artboard's left edge, from outside.
let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default());
let opt = HitOptions::default();
let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None));
let all = Rect::new(-10.0, -10.0, 1000.0, 800.0);
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.artboards[1].locked = true;
for id in [c, g, m1, m2] {
assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard");
}
for id in [a, b, e, layer] {
assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't");
}
assert_eq!(d.selectable_art(), [a, b, e]);
assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c");
assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard");
let isolated = HitOptions { scope: Some(g), ..opt };
assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock");
assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty());
assert_eq!(marquee(&d, all, None, false), [a, b, e]);
assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too");
// Deselected in one pass, with the key object and the anchors.
let mut sel = Selection::default();
sel.set([a, c]);
sel.key = Some(c);
sel.anchors.insert(c, [(0, 0)].into());
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true));
// A targeted layer stays targeted, with its art off the locked artboard selected.
sel.set_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer)));
// Shift-clicking the layer's target circle removes its art off the locked artboard, and
// a second Shift-click adds it again.
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![], None));
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!(sel.objects, [a, b, e]);
// A sublayer with art on both artboards: its art off the locked artboard can still be clicked.
let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap();
for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] {
d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art()))
.unwrap();
}
assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None));
assert!(!d.on_locked_artboard(sub));
let mut both = Selection::default();
both.set([s1, s2]);
both.deselect_on_locked_artboards(&d);
assert_eq!(both.objects, [s1]);
// New art on it is editable and can be clicked while it is new art.
d.new_art.set(Arc::new([c].into()));
assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art");
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.new_art.set(Default::default());
d.artboards[1].locked = false;
assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]);
assert_eq!(top(&d, 955.0, 55.0), Some(g));
assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]);
}
#[test]
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
+77 -12
View File
@@ -587,12 +587,18 @@ fn even_steps(n: u32) -> Vec<f64> {
(0..=n).map(|i| i as f64 / n as f64).collect()
}
/// The number of points of a `(rows+1)×(cols+1)` grid, `None` past `usize`: counts come from
/// files, and u32::MAX rows and columns (65,535 on 32-bit targets) would wrap to an empty grid.
fn grid_len(rows: u32, cols: u32) -> Option<usize> {
(rows as usize).checked_add(1)?.checked_mul((cols as usize).checked_add(1)?)
}
impl GradientMesh {
pub fn idx(&self, r: usize, c: usize) -> usize {
r * (self.cols as usize + 1) + c
}
pub fn is_valid(&self) -> bool {
self.rows >= 1 && self.cols >= 1 && self.points.len() == (self.rows as usize + 1) * (self.cols as usize + 1)
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len())
}
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
@@ -730,19 +736,35 @@ impl GradientMesh {
.to_point()
}
/// Bilinear colour and opacity of patch (r, c) at local (u, v).
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
let q =
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
let cmyk: Option<Vec<[f32; 4]>> =
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
if let Some(inks) = cmyk {
let mut mix = [0.0f32; 4];
for (ink, w) in inks.iter().zip(w) {
for (m, x) in mix.iter_mut().zip(ink) {
*m += x * w as f32;
}
}
let [c, m, y, k] = mix;
return (Color::Cmyk { c, m, y, k }, op);
}
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
if let Some(ks) = grey {
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
}
let mut rgb = [0.0f32; 3];
let mut op = 0.0f32;
for k in 0..4 {
let c = q[k].color.to_rgb();
for (ch, x) in rgb.iter_mut().zip(c) {
*ch += x * w[k] as f32;
for (p, w) in q.iter().zip(w) {
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
*ch += x * w as f32;
}
op += q[k].opacity * w[k] as f32;
}
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
@@ -1010,7 +1032,8 @@ impl GradientMesh {
/// Mesh tool Alt-click: delete the mesh lines through point `index` (interior lines only).
pub fn remove_point_lines(&mut self, index: usize) -> bool {
if index >= self.points.len() {
// A mesh from a file may not have the points its rows and columns need: it is indexed below.
if !self.is_valid() || index >= self.points.len() {
return false;
}
let w = self.cols as usize + 1;
@@ -1142,7 +1165,7 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
{
return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v));
}
if *rows == 0 || *cols == 0 || points.len() as u64 != (u64::from(*rows) + 1) * (u64::from(*cols) + 1) {
if *rows == 0 || *cols == 0 || grid_len(*rows, *cols) != Some(points.len()) {
return Box::new(flat);
}
let (rows, cols) = (*rows as usize, *cols as usize);
@@ -1194,8 +1217,7 @@ pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]>
/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the
/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid.
pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option<GradientMesh> {
// (Counts come from files: no overflow on 32-bit targets.)
if rows == 0 || cols == 0 || points.len() as u64 != (u64::from(rows) + 1) * (u64::from(cols) + 1) {
if rows == 0 || cols == 0 || grid_len(rows, cols) != Some(points.len()) {
return None;
}
let smooth;
@@ -1813,6 +1835,20 @@ mod tests {
assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}");
}
/// A file's rows and columns can be anything: a point count too large to compute is no match
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
#[test]
fn a_grid_too_large_to_count_is_malformed() {
let m = GradientMesh { rows: u32::MAX, cols: u32::MAX, points: vec![] };
assert!(!m.is_valid());
assert_eq!((m.lines(), m.outline(), m.quads(8).len()), (PathData::default(), PathData::default(), 0));
assert!(envelope_grid(u32::MAX, u32::MAX, &[], &[]).is_none());
// A mesh envelope without handles maps through its points: malformed, it leaves art as it is.
let kind = EnvelopeKind::Mesh { rows: u32::MAX, cols: u32::MAX, points: vec![], handles: vec![] };
let p = Point::new(25.0, 50.0);
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
}
#[test]
fn mesh_insert_and_remove_lines() {
let mut m =
@@ -1823,4 +1859,33 @@ mod tests {
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
}
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
#[test]
fn a_cmyk_mesh_mixes_its_inks() {
let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap();
m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
let (c, op) = m.color_at(0, 0, 0.0, 0.0);
assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0));
let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") };
assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}");
// A corner in another space mixes them all in RGB.
m.points[1].color = Color::rgb(1.0, 0.0, 0.0);
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
}
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
/// deletes nothing (it indexed past the points, a panic).
#[test]
fn mesh_lines_of_a_malformed_mesh_stay() {
let point = |i: u32| MeshPoint { p: Point::new(f64::from(i), 0.0), color: Color::BLACK, opacity: 1.0, handles: [Vec2::ZERO; 4] };
// An interior row's point and an interior column's point, each with 3 of the 6 points.
for (rows, cols, index) in [(2, 1, 2), (1, 2, 1)] {
let mut m = GradientMesh { rows, cols, points: (0..3).map(point).collect() };
let before = m.clone();
assert!(!m.remove_point_lines(index), "{rows}×{cols}, point {index}");
assert_eq!(m, before);
}
}
}
+3
View File
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
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 += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
+59
View File
@@ -82,6 +82,31 @@ impl DocMetadata {
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
/// bytes twice (#2880).
///
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
/// encryption: neither may be pinned by the environment.
pub fn output_time() -> Option<i64> {
pinned_or(|n| std::env::var(n).ok(), now_unix)
}
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
/// variable is preferred and that the clock answers whenever it does not.
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
stamp_from_env(get).or_else(clock)
}
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
/// the clock decides — an export is never refused over the environment.
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
@@ -148,4 +173,38 @@ mod tests {
);
assert!(DocMetadata::default().png_text("").is_empty());
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
}
assert_eq!(stamp_from_env(env(None)), None, "unset");
// Another variable never stands in for it.
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
}
/// What a file records: the variable when it is usable, the clock whenever it is not. The
/// environment and the clock are passed in, so no test changes this process's environment --
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
#[test]
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
let clock = || Some(1_000_000);
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
for ignored in [None, Some(""), Some("-1"), Some("later")] {
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
}
// A platform with no clock and no variable has no date to record.
assert_eq!(pinned_or(env(None), || None), None);
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
}
}
+151 -36
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
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};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
@@ -88,6 +88,9 @@ pub enum LiveShape {
/// Corner kinds, in the same order (Live Corners' corner types).
#[serde(default, skip_serializing_if = "all_round")]
kinds: [CornerKind; 4],
/// When true, `radii` are fractions in 0..1 of each corner's largest possible radius.
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
@@ -102,6 +105,8 @@ pub enum LiveShape {
},
Polygon {
radius: f64,
/// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read.
#[serde(deserialize_with = "de_sides")]
sides: u32,
xf: Affine,
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
@@ -110,6 +115,8 @@ pub enum LiveShape {
/// Corner kinds, in the same order (none: round).
#[serde(default, skip_serializing_if = "all_round")]
kinds: Vec<CornerKind>,
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
},
Line {
a: Point,
@@ -127,10 +134,24 @@ pub enum LiveShape {
/// Corner kinds, in the same order (missing: round).
#[serde(default, skip_serializing_if = "all_round")]
kinds: Vec<CornerKind>,
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
},
}
fn is_false(b: &bool) -> bool {
!*b
}
impl LiveShape {
pub fn radii_relative(&self) -> bool {
match self {
LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => {
*radii_relative
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false,
}
}
pub fn label(&self) -> &'static str {
match self {
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle",
@@ -144,13 +165,23 @@ impl LiveShape {
/// Regenerate the path.
pub fn to_path(&self) -> PathData {
match self {
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => {
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative);
let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0));
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
LiveShape::Polygon { radii, kinds, radii_relative, .. } => {
let outline = self.polygon_outline();
let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative);
cut_corners(&outline, &radii, kinds).0
}
LiveShape::Line { a, b } => shapes::line(*a, *b),
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
LiveShape::Path { base, radii, kinds, radii_relative } => {
let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative);
cut_corners(base, &radii, kinds).0
}
}
}
/// A polygon's outline with its corners uncut, in the document (empty for other shapes).
@@ -251,13 +282,13 @@ impl LiveShape {
*xf = a * *xf;
false
}
LiveShape::Polygon { xf, radii, .. } => {
LiveShape::Polygon { xf, radii, radii_relative, .. } => {
*xf = a * *xf;
scale_radii(radii, a)
scale_radii(radii, a, *radii_relative)
}
LiveShape::Path { base, radii, .. } => {
LiveShape::Path { base, radii, radii_relative, .. } => {
base.transform(a);
scale_radii(radii, a)
scale_radii(radii, a, *radii_relative)
}
LiveShape::Line { a: p, b } => {
*p = a * *p;
@@ -272,7 +303,7 @@ impl LiveShape {
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
/// uneven scale in `xf`: their corners are elliptical until this runs.
pub fn fold_scale(&mut self) -> bool {
let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false };
let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false };
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
@@ -282,7 +313,9 @@ impl LiveShape {
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
if !*radii_relative {
radii.iter_mut().for_each(|r| *r *= k);
}
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
@@ -298,12 +331,13 @@ impl LiveShape {
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
/// keep their size (Scale Corners off). False when nothing changed.
pub fn keep_corners(&mut self, k: f64) -> bool {
let relative = self.radii_relative();
let radii: &mut [f64] = match self {
LiveShape::Rectangle { radii, .. } => radii,
LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii,
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false,
};
if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
return false;
}
radii.iter_mut().for_each(|r| *r /= k);
@@ -326,6 +360,12 @@ fn all_round(kinds: &[CornerKind]) -> bool {
kinds.iter().all(|k| *k == CornerKind::Round)
}
/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its
/// corner lists hold one entry per side, so a huge count would allocate gigabytes.
fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
u32::deserialize(d).map(|n| n.clamp(3, 1000))
}
/// Whether `a` only moves (no rotation, scale, shear or reflection).
fn moves_only(a: Affine) -> bool {
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
@@ -335,8 +375,8 @@ fn moves_only(a: Affine) -> bool {
/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True
/// when the path must be cut again: some corner is cut and `a` does more than move it (an
/// uneven scale would otherwise make the cuts elliptical).
fn scale_radii(radii: &mut [f64], a: Affine) -> bool {
if moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool {
if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
return false;
}
let k = a.determinant().abs().sqrt();
@@ -572,6 +612,9 @@ pub struct NodeExtras {
pub intertwine: Option<Box<PathData>>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -590,6 +633,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
dimension: None,
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -724,6 +768,7 @@ impl Serialize for Node {
e.dimension.is_none(),
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -762,6 +807,7 @@ impl Serialize for Node {
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
st.end()
}
}
@@ -821,6 +867,8 @@ struct NodeWire {
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
impl From<NodeWire> for Node {
@@ -840,6 +888,7 @@ impl From<NodeWire> for Node {
dimension: w.dimension,
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
};
Node {
id: w.id,
@@ -1025,6 +1074,7 @@ impl Node {
match &self.kind {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -1087,8 +1137,10 @@ 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. Symbol instances are
/// measured as in [`Node::visual_bounds`].
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
@@ -1106,6 +1158,22 @@ impl Node {
let o = self.appearance.outset();
self.geometric(symbols).map(|b| b.inflate(o, o))
}
// The members' reach, grown by the container's own strokes.
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
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.reach(symbols)))
.map(|b| b.inflate(o, o))
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
// the farthest it can reach from its shape.
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
_ => self.visual(symbols),
}
}
@@ -1123,6 +1191,17 @@ impl Node {
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.geometric(Some(symbols))
}
/// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a
/// hidden bitmap for re-tracing/release; only source-image views measure that bitmap.
pub fn editing_children(&self) -> &[Arc<Node>] {
let children = self.children().map(Vec::as_slice).unwrap_or_default();
let trace_source = self.extras().trace.is_some()
&& !self.trace_view().shows_source()
&& children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_)));
let skip = usize::from(self.extras().shaper.is_some() || trace_source);
children.get(skip..).unwrap_or_default()
}
/// 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> {
@@ -1132,9 +1211,9 @@ impl Node {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric(symbols))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self
.editing_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(symbols))),
NodeKind::Text(t) => self.projected(t.bounds()),
@@ -1155,9 +1234,9 @@ impl Node {
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// 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).
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). An image is
/// measured by its frame. 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)
}
@@ -1187,14 +1266,10 @@ impl Node {
}
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
// 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(symbols)))
.map(|b| b.inflate(o, o))
self.editing_children().iter().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(symbols)));
@@ -1232,6 +1307,8 @@ impl Node {
let o = self.appearance.outset().max(chars);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
// An image is measured by its frame, without its own fills and strokes.
NodeKind::Image(_) => self.geometric(symbols),
_ => {
let o = self.appearance.outset();
self.geometric(symbols).map(|b| b.inflate(o, o))
@@ -1251,6 +1328,12 @@ impl Node {
{
dimension.transform(a);
}
// The frames of the objects attached to a path move with them.
if let Some(e) = self.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
@@ -1683,11 +1766,11 @@ impl Node {
impl Node {
/// The Appearance panel's row for what this object's own fills and strokes paint around:
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
/// whose fills and strokes paint their own geometry.
/// "Contents" for a group or layer (its members) and for an image (its pixels), "Characters"
/// for type; `None` for objects whose fills and strokes paint their own geometry.
pub fn contents_label(&self) -> Option<&'static str> {
match self.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Image(_) => Some("Contents"),
NodeKind::Text(_) => Some("Characters"),
_ => None,
}
@@ -2124,14 +2207,28 @@ mod tests {
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
let l = LiveShape::Rectangle {
w: 10.0,
h: 20.0,
radii: [0.0; 4],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate((5.0, 5.0)),
};
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn corner_kinds_save_only_when_some_corner_isnt_round() {
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
let mut l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [0.0, 10.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
// Older files read as round.
let json = serde_json::to_value(&l).unwrap();
assert!(json.get("kinds").is_none(), "{json}");
@@ -2145,21 +2242,39 @@ mod tests {
// Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips.
let p: LiveShape =
serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap();
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] });
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false });
let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0);
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] };
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false };
let json = serde_json::to_value(&path).unwrap();
assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None));
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), path);
assert_eq!(path.label(), "Path");
}
/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one
/// with four billion made its outline and corner lists allocate tens of gigabytes.
#[test]
fn a_polygon_from_a_file_has_3_to_1000_sides() {
let read = |sides: u32| {
let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]});
serde_json::from_value::<LiveShape>(json).unwrap()
};
let sides_of = |l: &LiveShape| match l {
LiveShape::Polygon { sides, .. } => *sides,
_ => panic!("not a polygon: {l:?}"),
};
let huge = read(4_000_000_000);
assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]);
assert_eq!(sides_of(&huge), 1000);
assert_eq!(huge.polygon_outline().anchor_count(), 1000);
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
@@ -2184,7 +2299,7 @@ mod tests {
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(Box::new(live));
+376
View File
@@ -0,0 +1,376 @@
//! Object › Objects on Path: objects attached to a path and spread along it.
//!
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
//! saves and exports as a group. The record lists the objects in their order along the path, each
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
//! one sits on the end of an open path), puts the attachment point of each one
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
//! tilted by [`ObjectsOnPath::rotate`].
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, PathData, Point};
use crate::blend::Spine;
use crate::symbols::SymbolBoxes;
use crate::{Document, Node, NodeId, NodeKind};
/// The center of the nine reference points.
pub const CENTER: u8 = 4;
fn center() -> u8 {
CENTER
}
/// What makes a group an Objects on Path group (see the module docs).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ObjectsOnPath {
/// The child of the group that is the path.
pub path: NodeId,
/// The attached objects, in their order along the path.
#[serde(default)]
pub members: Vec<Member>,
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
/// from the top left of the object's bounds in its frame (4: the center).
#[serde(default = "center")]
pub pivot: u8,
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
/// upright object has its frame's x axis along the path's direction at its attachment point.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub rotate: f64,
}
/// An object attached to the path of an Objects on Path group.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "MemberWire")]
pub struct Member {
pub id: NodeId,
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
pub frame: Affine,
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
/// the frame's x axis before then.
pub angle: f64,
}
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
#[derive(Deserialize)]
struct MemberWire {
id: NodeId,
frame: Affine,
#[serde(default)]
angle: Option<f64>,
}
impl From<MemberWire> for Member {
fn from(w: MemberWire) -> Self {
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
Self { id: w.id, frame: w.frame, angle }
}
}
/// The angle of `frame`'s x axis (radians, the document's axes).
fn x_angle(frame: Affine) -> f64 {
let [a0, a1, ..] = frame.as_coeffs();
a1.atan2(a0)
}
impl Member {
/// `n` in its frame as drawn ([`upright_frame`]).
pub fn new(n: &Node) -> Self {
let frame = upright_frame(n);
Self { id: n.id, frame, angle: x_angle(frame) }
}
}
impl ObjectsOnPath {
/// A record for the objects `members` (in their order along the path) attached to `path`, each
/// in its frame as drawn ([`upright_frame`]).
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
let members = members.iter().map(|n| Member::new(n)).collect();
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
}
/// The frames go with the group, and each frame's angle becomes the transformed path's
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
/// and reflection when it spreads the objects along the transformed path again. A reflection
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
pub fn transform(&mut self, a: Affine) {
let before = tilt(self.rotate);
if a.determinant() < 0.0 && self.rotate != 0.0 {
self.rotate = -self.rotate;
}
let after = tilt(self.rotate);
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
for m in &mut self.members {
m.frame = a * m.frame;
let (sin, cos) = (m.angle - before).sin_cos();
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
m.angle = y.atan2(x) + after;
}
}
}
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
self.path = to(self.path);
for m in &mut self.members {
m.id = to(m.id);
}
}
/// The group's children changed from `before` to `after`: a new child at the index of a member
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
let mut ids = HashMap::new();
for (old, new) in before.iter().zip(after) {
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
ids.insert(old.id, new.id);
}
}
if !ids.is_empty() {
self.rename(&ids);
}
}
}
/// Is `n` an Objects on Path group?
pub fn is_objects_on_path(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
}
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
/// (not a guide) whose first subpath has some length.
pub fn attachable_path(n: &Node) -> Option<&PathData> {
match &n.kind {
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
_ => None,
}
}
/// What keeps an object from going into an Objects on Path group, as its path or as one of its
/// objects ([`held`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Held {
/// It is inside a compound path, blend, envelope, repeat or Intertwine group, as part of its art.
Live,
/// It is the clipping path of a clip group or of a layer with a clipping mask, or inside one.
Clipping,
}
/// What keeps the object `id` of `d` where it is ([`Held`]), if anything: in an Objects on Path
/// group it would no longer be a part of the compound path, blend, envelope, repeat or Intertwine
/// group it is in, or of the clipping path it is or is in. The innermost reason counts. `None` for
/// an object that is neither a clipping path nor inside one and is in a layer directly or through
/// plain groups and clip groups, and for an id not in `d`.
pub fn held(d: &Document, id: NodeId) -> Option<Held> {
let path = d.index_path(id)?;
let mut chain: Vec<&Node> = Vec::with_capacity(path.len());
let mut nodes = &d.layers;
for &i in &path {
let n: &Node = nodes.get(i)?;
chain.push(n);
if let Some(children) = n.children() {
nodes = children;
}
}
for (depth, n) in chain.iter().enumerate().rev() {
let Some(parent) = depth.checked_sub(1).and_then(|up| chain.get(up)) else { break };
if matches!(n.kind, NodeKind::Path { clipping: true, .. }) || (parent.clips() && path.get(depth) == Some(&0)) {
return Some(Held::Clipping);
}
if matches!(
parent.kind,
NodeKind::Compound { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. }
) {
return Some(Held::Live);
}
}
None
}
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
/// that frame, an object stands square to the path or across it.
pub fn upright_frame(n: &Node) -> Affine {
let a = n.bbox_angle;
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
match n.geometric_bounds() {
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
_ => Affine::IDENTITY,
}
}
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
/// around. `None` for a path without length.
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
let spine = Spine::new(path)?;
let len = spine.length();
if !len.is_finite() || len <= 1e-9 {
return None;
}
let closed = path.subpaths.first().is_some_and(|s| s.closed);
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
}
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
/// counter-clockwise on the page.
fn tilt(rotate: f64) -> f64 {
-rotate.to_radians()
}
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
let det = m.frame.determinant();
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
return None;
}
let mut local = n.clone();
local.transform(m.frame.inverse(), false);
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
}
/// Is `a` the identity, give or take rounding?
fn negligible(a: Affine) -> bool {
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
}
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
/// record lets go of the objects that are no longer in the group, and children it does not list
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
let old = rec.clone();
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
let path_id = rec.path;
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
g.extras_mut().objects_on_path = None;
return true;
};
let path = attachable_path(path).cloned();
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
for c in children.iter() {
if c.id != path_id && listed.insert(c.id) {
rec.members.push(Member::new(c));
}
}
let mut moved = false;
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
let turn = tilt(rec.rotate);
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
let delta = next * m.frame.inverse();
if !negligible(delta) {
Arc::make_mut(c).transform(delta, false);
*m = Member { id: m.id, frame: next, angle: dir + turn };
moved = true;
}
}
}
if rec != old {
g.extras_mut().objects_on_path = Some(Box::new(rec));
return true;
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::{Rect, shapes};
fn rect(id: u64, r: Rect) -> Node {
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
}
#[test]
fn stops_spread_from_the_first_anchor() {
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let s = stops(&open, 4).unwrap();
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let s = stops(&closed, 4).unwrap();
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
pts.sort();
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
}
#[test]
fn upright_frames_round_the_rotation_to_a_right_angle() {
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 90.0;
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 30.0;
let f = upright_frame(&n);
let [a0, a1, ..] = f.as_coeffs();
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
}
#[test]
fn a_symbol_instance_is_placed_by_its_art() {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
let members = [&instance, &other];
let mut g = Node::new(
NodeId(4),
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
);
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
let symbols = d.symbol_boxes();
assert!(place(&mut g, &symbols));
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
let b = placed.geometric_bounds_with(&symbols).unwrap();
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
}
#[test]
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
let mut v = serde_json::to_value(m).unwrap();
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
v.as_object_mut().unwrap().remove("angle");
let back: Member = serde_json::from_value(v).unwrap();
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
}
#[test]
fn a_group_without_its_path_is_a_plain_group() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
let d = crate::Document::new(100.0, 100.0);
assert!(place(&mut g, &d.symbol_boxes()));
assert!(!is_objects_on_path(&g));
}
}
+2 -2
View File
@@ -90,8 +90,8 @@ impl Node {
/// ([`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(symbols, a, visual)));
NodeKind::Group { clip: false, .. } => {
let b = self.editing_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))
+74 -1
View File
@@ -467,6 +467,14 @@ impl RepeatKind {
RepeatKind::Mirror { .. } => "Mirror Repeat",
}
}
/// How many instances [`repeat_transforms`] makes (with its clamps).
fn count(&self) -> usize {
match self {
RepeatKind::Radial { instances, .. } => (*instances).clamp(1, 1000) as usize,
RepeatKind::Grid { rows, cols, .. } => (*rows).clamp(1, 500) as usize * (*cols).clamp(1, 500) as usize,
RepeatKind::Mirror { .. } => 2,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -475,6 +483,23 @@ pub struct RepeatSpec {
pub source: Vec<Arc<Node>>,
}
/// Most instances a Repeat makes in all, those of the Repeats nested in its source included: as
/// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps.
const MAX_REPEAT_INSTANCES: usize = 250_000;
/// Instances the Repeats in `nodes` make in all (0 when there are none).
fn nested_instances(nodes: &[Arc<Node>]) -> usize {
let mut sum = 0usize;
for n in nodes {
sum = sum.saturating_add(match (&n.kind, n.children()) {
(NodeKind::Repeat(r), _) => r.instances_in_all(),
(_, Some(c)) => nested_instances(c),
_ => 0,
});
}
sum
}
/// Reflection across the line through `c` with direction angle `deg`.
pub fn reflect_about(c: Point, deg: f64) -> Affine {
let t = (2.0 * deg).to_radians();
@@ -536,7 +561,26 @@ impl RepeatSpec {
crate::live::nodes_bounds(&self.source)
}
pub fn transforms(&self) -> Vec<Affine> {
repeat_transforms(&self.kind, self.source_bounds())
let mut v = repeat_transforms(&self.kind, self.source_bounds());
// Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500
// grid of one is 62.5 billion instances): keep the first that fit the budget.
v.truncate(self.kept(nested_instances(&self.source)));
v
}
/// How many of its instances this Repeat keeps when each makes `nested` instances of the
/// Repeats in its source: as many as fit in [`MAX_REPEAT_INSTANCES`], at least one.
fn kept(&self, nested: usize) -> usize {
self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1)
}
/// How many instances this Repeat draws ([`Self::transforms`]): as many as its options make,
/// fewer when the Repeats in its source would take it past [`MAX_REPEAT_INSTANCES`].
pub fn instances(&self) -> usize {
self.kept(nested_instances(&self.source))
}
/// Instances made in all, those of the Repeats nested in the source included.
fn instances_in_all(&self) -> usize {
let nested = nested_instances(&self.source);
self.kept(nested).saturating_mul(nested.max(1))
}
/// Evaluated instances: one group of the (transformed) source per instance (ids 0).
pub fn expand(&self) -> Vec<Node> {
@@ -838,6 +882,35 @@ mod tests {
assert!((angle.rem_euclid(180.0) - 0.0).abs() < 1e-9 || (angle.rem_euclid(180.0) - 180.0).abs() < 1e-9, "{angle}");
}
#[test]
fn nested_repeats_share_one_instance_budget() {
// Instances made in all, counted from the transforms without drawing any.
fn made(r: &RepeatSpec) -> usize {
let each: usize = r.source.iter().map(|s| if let NodeKind::Repeat(n) = &s.kind { made(n) } else { 1 }).sum();
r.transforms().len().saturating_mul(each)
}
let grid = |source: Arc<Node>| {
let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0);
if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind {
*rows = 500;
*cols = 500;
}
g
};
let repeat = |r: RepeatSpec| Arc::new(Node::new(NodeId(2), NodeKind::Repeat(r)));
// One grid keeps all its instances.
let inner = grid(sq(0.0, 0.0, 1.0));
assert_eq!(made(&inner), 250_000);
// A 500 × 500 grid of it made 62.5 billion.
let outer = grid(repeat(inner.clone()));
assert!(made(&outer) <= 250_000, "{} instances", made(&outer));
assert_eq!(outer.expand().len(), outer.transforms().len());
assert!(made(&RepeatSpec::mirror(vec![repeat(inner)], 90.0, 5.0)) <= 250_000);
// Small nests are left as they are.
let small = RepeatSpec::mirror(vec![repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 1.0))], 90.0, 5.0);
assert_eq!(made(&small), 18);
}
#[test]
fn serde_roundtrip() {
let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]);
+65 -13
View File
@@ -1,6 +1,7 @@
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -43,6 +44,16 @@ pub struct Selection {
/// guides and objects can be selected together, in either order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<usize>,
/// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index
/// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set
/// with `key`; [`Self::prune`] clears it when no object is selected to align to it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_guide: Option<usize>,
/// The new art on a locked artboard that this selection keeps editable
/// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and
/// the engine then sets the document's from it. Editing state, never saved.
#[serde(skip)]
pub new_art: Arc<HashSet<NodeId>>,
}
impl Selection {
@@ -67,6 +78,11 @@ impl Selection {
self.target = None;
self.slices.clear();
self.guides.clear();
self.key_guide = None;
}
/// Is there a key for Align: an object or a guide?
pub fn has_key(&self) -> bool {
self.key.is_some() || self.key_guide.is_some()
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
@@ -114,6 +130,9 @@ impl Selection {
pub fn toggle_guide(&mut self, i: usize) {
if let Some(k) = self.guides.iter().position(|g| *g == i) {
self.guides.remove(k);
if self.key_guide == Some(i) {
self.key_guide = None;
}
} else {
self.guides.push(i);
}
@@ -135,7 +154,11 @@ impl Selection {
if self.anchors.is_empty() {
return None;
}
let mut acc = doc.bounds_of(&self.whole(), visual);
vectorcraft_geom::union_opt(doc.bounds_of(&self.whole(), visual), self.anchors_box(doc))
}
/// The box around the anchors selected with Direct Selection. `None` without any.
pub fn anchors_box(&self, doc: &Document) -> Option<vectorcraft_geom::Rect> {
let mut acc = None;
for (id, set) in &self.anchors {
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
@@ -162,13 +185,16 @@ impl Selection {
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
});
// The key object stays while another selected object lies outside it.
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
// The key object stays while another selected object lies outside it, or while ruler
// guides are selected for it to line up (#2143).
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
});
self.objects.iter().any(|o| !inside.contains(o))
self.objects.iter().any(|o| !inside.contains(o)) || (self.objects.contains(&n.id) && !self.guides.is_empty())
});
if !keep {
self.key = None;
@@ -177,23 +203,45 @@ impl Selection {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
// A key guide needs objects to line up on it, and stays selected.
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
self.key_guide = None;
}
}
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
/// around them (and so the key object when it is one): hidden or locked art can't stay
/// selected.
pub fn deselect_uneditable(&mut self, doc: &Document) {
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
if uneditable.is_empty() {
let locks = doc.locked_artboards();
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable_with(&locks, *o)).collect();
self.deselect_all(&uneditable);
}
/// Deselect the objects on a locked artboard ([`crate::LockedArtboards::holds_id`]), whatever
/// selected them or moved them there (and so the key object when it is one): they act as
/// locked art. A targeted layer stays targeted, as it does with locked objects in it.
pub fn deselect_on_locked_artboards(&mut self, doc: &Document) {
let locks = doc.locked_artboards();
if locks.is_empty() {
return;
}
let locked: HashSet<NodeId> = self.objects.iter().copied().filter(|o| locks.holds_id(*o)).collect();
let layer = self.target.filter(|t| doc.node(*t).is_some_and(crate::Node::is_layer));
self.deselect_all(&locked);
if layer.is_some() {
self.target = layer;
}
}
/// Deselect the objects `gone` in one pass, with their anchors, segments and key.
fn deselect_all(&mut self, gone: &HashSet<NodeId>) {
if gone.is_empty() {
return;
}
self.target = None;
self.slices.clear();
self.objects.retain(|o| !uneditable.contains(o));
self.anchors.retain(|id, _| !uneditable.contains(id));
self.segments.retain(|id, _| !uneditable.contains(id));
if self.key.is_some_and(|k| uneditable.contains(&k)) {
self.objects.retain(|o| !gone.contains(o));
self.anchors.retain(|id, _| !gone.contains(id));
self.segments.retain(|id, _| !gone.contains(id));
if self.key.is_some_and(|k| gone.contains(&k)) {
self.key = None;
}
}
@@ -207,12 +255,16 @@ impl Selection {
self.target = Some(id);
}
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
/// when it is all selected already. Several objects are then selected, none targeted.
/// when it is all selected already. Several objects are then selected, none targeted. Art on a
/// locked artboard is neither counted nor added, since
/// [`Selection::deselect_on_locked_artboards`] deselects it.
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
let ids = match doc.node(id) {
let mut ids = match doc.node(id) {
Some(n) if n.is_layer() => n.layer_art(true),
_ => vec![id],
};
let locks = doc.locked_artboards();
ids.retain(|i| !locks.holds_id(*i));
if ids.iter().all(|i| self.contains(*i)) {
ids.iter().for_each(|i| self.remove(*i));
} else {
+6
View File
@@ -238,6 +238,11 @@ pub struct DocSetup {
pub export_text: ExportText,
/// Background Contents chosen in New Document.
pub background: Background,
/// Character panel menu › System Layout: every text object advances by whole points. Off by
/// default and for documents from before it, and saved only when on.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
#[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")]
pub system_layout: bool,
}
impl Default for DocSetup {
@@ -260,6 +265,7 @@ impl Default for DocSetup {
small_caps_size: 70.0,
export_text: ExportText::Editable,
background: Background::Transparent,
system_layout: false,
}
}
}
+1 -1
View File
@@ -297,7 +297,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
/// 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)) {
pub 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);
+7
View File
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
assert_eq!(Unit::named(""), None);
}
#[test]
fn a_shared_suffix_names_the_plain_unit() {
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
}
#[test]
fn readouts_show_two_decimals() {
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
+398 -2
View File
@@ -109,10 +109,22 @@ pub struct CharStyle {
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
/// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters
/// stand side by side across one em of the column. Horizontal type keeps the mark and does not
/// move them. Two or three half-width digits do this without the mark.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")]
pub tate_chu_yoko: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
/// No Break (Character panel menu): these characters are not broken across lines, including
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
/// and can overflow it.
/// <https://helpx.adobe.com/illustrator/using/hyphenation-line-breaks.html>
#[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")]
pub no_break: bool,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
@@ -249,6 +261,9 @@ fn regular() -> String {
fn hundred() -> f64 {
100.0
}
fn yes() -> bool {
true
}
impl Default for CharStyle {
fn default() -> Self {
@@ -283,8 +298,10 @@ impl Default for CharStyle {
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
tate_chu_yoko: false,
small_caps: None,
digits: Digits::Default,
no_break: false,
}
}
}
@@ -506,6 +523,127 @@ impl Default for HyphenationOptions {
}
}
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SingleWordJustify {
/// Spread across the line, between its letters.
#[default]
FullJustify,
AlignLeft,
AlignCenter,
AlignRight,
}
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
///
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JustificationOptions {
/// Word Spacing, percent of the font's word space (0..1000).
pub word_spacing: [f64; 3],
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
pub letter_spacing: [f64; 3],
/// Glyph Scaling, percent of the glyphs' width (50..200).
pub glyph_scaling: [f64; 3],
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
/// (0..500).
pub auto_leading: f64,
/// Single Word Justification: how a justified line holding one word is set.
pub single_word: SingleWordJustify,
}
impl JustificationOptions {
/// Word Spacing's range (percent).
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
/// Letter Spacing's range (percent).
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
/// Glyph Scaling's range (percent).
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
/// Auto Leading's range (percent).
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
/// in a line's natural widths)?
pub fn desired_is_default(&self) -> bool {
let d = Self::default();
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
}
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
/// Desired and Maximum out of order go back to their defaults together.
pub fn keep_in_range(&mut self) {
let d = Self::default();
for (v, dv, (lo, hi)) in [
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
] {
for (x, dx) in v.iter_mut().zip(dv) {
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
}
if !(v[0] <= v[1] && v[1] <= v[2]) {
*v = dv;
}
}
let (lo, hi) = Self::AUTO_LEADING;
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
}
/// A paragraph's leading for characters of `size` whose leading is Auto.
pub fn auto_leading_for(&self, size: f64) -> f64 {
size * self.auto_leading / 100.0
}
}
impl Default for JustificationOptions {
fn default() -> Self {
Self {
word_spacing: [80.0, 100.0, 133.0],
letter_spacing: [0.0, 0.0, 0.0],
glyph_scaling: [100.0, 100.0, 100.0],
auto_leading: 120.0,
single_word: SingleWordJustify::FullJustify,
}
}
}
/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of
/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair
/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter
/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font
/// draws them, at most an em long.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kashida {
/// No kashidas: justified lines widen their word spaces.
#[default]
None,
Short,
Medium,
Long,
Stylistic,
}
impl Kashida {
/// The longest kashida, in ems of the letter that follows it in the text; 0 for None.
pub fn max_em(self) -> f64 {
match self {
Kashida::None => 0.0,
Kashida::Short => 0.25,
Kashida::Medium => 0.5,
Kashida::Long | Kashida::Stylistic => 1.0,
}
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -522,6 +660,9 @@ pub struct ParaStyle {
pub hyphenate: bool,
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
pub hyphenation: HyphenationOptions,
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
/// (Paragraph panel menu › Justification…).
pub justification: JustificationOptions,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
@@ -550,6 +691,9 @@ pub struct ParaStyle {
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
pub roman_hanging: bool,
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
/// kashidas before they widen their word spaces.
pub kashida: Kashida,
}
impl ParaStyle {
@@ -591,6 +735,8 @@ struct ParaStyleFile {
hyphenate: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
hyphenation: HyphenationOptions,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
justification: JustificationOptions,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -609,6 +755,8 @@ struct ParaStyleFile {
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -627,6 +775,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
style_name,
@@ -636,6 +785,7 @@ impl From<ParaStyle> for ParaStyleFile {
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = p;
Self {
@@ -648,6 +798,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
style_name,
@@ -657,6 +808,7 @@ impl From<ParaStyle> for ParaStyleFile {
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
@@ -664,6 +816,8 @@ impl From<ParaStyle> for ParaStyleFile {
impl From<ParaStyleFile> for ParaStyle {
fn from(f: ParaStyleFile) -> Self {
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
let mut justification = f.justification;
justification.keep_in_range();
let ParaStyleFile {
left_indent,
right_indent,
@@ -681,6 +835,7 @@ impl From<ParaStyleFile> for ParaStyle {
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = f;
Self {
@@ -692,6 +847,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
style_name,
@@ -701,6 +857,7 @@ impl From<ParaStyleFile> for ParaStyle {
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
@@ -1085,6 +1242,10 @@ fn one() -> f64 {
1.0
}
fn fifty() -> f64 {
50.0
}
impl InlineArt {
pub fn new(symbol: impl Into<String>) -> Self {
Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }
@@ -1150,6 +1311,68 @@ fn reverse_from_end(path: &mut PathData) {
}
}
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
/// vertical type (the Japanese convention), or below (left in vertical type).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RubyPosition {
#[default]
Above,
Below,
}
/// How ruby text lines up over its base text when the two are different widths.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RubyAlign {
/// Centred over the base text.
#[default]
Center,
/// Starting at the base text's start (left in horizontal type, top in vertical).
Start,
/// Ending at the base text's end (right in horizontal type, bottom in vertical).
End,
}
/// Ruby (furigana, phonetic guide): a reading set over a range of the text.
///
/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as
/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text;
/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and
/// allowed to overhang neighbouring characters when the reading is longer than its base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RubySpan {
/// Byte offset of the first base character in the plain text.
pub start: usize,
/// Byte offset just past the last base character.
pub end: usize,
/// The reading, set in the base characters' font (its size comes from `size_percent`).
pub text: String,
#[serde(default)]
pub position: RubyPosition,
#[serde(default)]
pub align: RubyAlign,
/// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual).
#[serde(default = "fifty")]
pub size_percent: f64,
/// Let a reading wider than its base hang over the neighbouring characters instead of being
/// squeezed to the base's width.
#[serde(default = "yes")]
pub overhang: bool,
}
impl RubySpan {
/// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging).
pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true }
}
/// `size_percent` made finite and usable: 1–100% (untrusted input).
pub fn safe_size_percent(&self) -> f64 {
if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 }
}
}
/// A text object. `runs` split into paragraphs at `\n`.
///
/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every
@@ -1165,6 +1388,10 @@ pub struct TextObject {
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
pub runs: Vec<TextRun>,
/// Ruby (furigana / phonetic guide) over byte ranges of the plain text; empty = none. Kept on
/// the object, so the runs (and their styles) stay the text itself.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ruby: Vec<RubySpan>,
#[serde(default)]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
@@ -1189,10 +1416,22 @@ pub struct TextObject {
pub wrap: Vec<WrapShape>,
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
/// sides of letters such as A and W at a line's start or end hang outside the margin there
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
/// for new type and for documents from before it, and saved only when on.
/// (point and area type, and type on a path at its brackets; see the layout's `optical_hangs`).
/// Off for new type and for documents from before it, and saved only when on.
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
pub optical_margin: bool,
/// Character panel menu › System Layout, copied from the document: when on, each character
/// that takes space advances by a whole number of points. Off by default, and saved only when
/// on. The document setting is [`crate::DocSetup::system_layout`].
#[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")]
pub system_layout: bool,
/// Character panel menu › Fractional Widths: spacing between characters uses fractions of a
/// point. On for new type and for documents from before it, and saved only when off. Off, each
/// character that takes any space advances by a whole number of points, at least one, so small
/// type does not run together. The setting is the whole type object, not a range of characters.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
#[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")]
pub fractional_widths: bool,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
@@ -1245,12 +1484,43 @@ impl TextObject {
Some(style)
}
/// Bakes the object's scale ([`style_scale`]) into character attributes and removes that
/// scale from `xf`, so panels show document-point sizes without a separate transform factor.
/// Returns `false` when there is nothing to bake (identity scale or an unrepresentable
/// transform).
pub fn scale_text_attributes(&mut self) -> bool {
let Some(scale) = self.style_scale() else { return false };
if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 {
return false;
}
let [a, b, c, d, _, _] = self.xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
if sx <= 1e-12 || sy <= 1e-12 {
return false;
}
let mut baked = Vec::with_capacity(self.runs.len());
for run in &self.runs {
let Some(eff) = self.effective_char_style(&run.style) else { return false };
baked.push(eff);
}
for (run, style) in self.runs.iter_mut().zip(baked) {
run.style = style;
}
// Character strokes are drawn in text space: widen them by the vertical scale we remove.
self.scale_char_strokes(1.0 / sy);
self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy);
self.cached_bounds = None;
self.cached_baselines.clear();
true
}
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun::new(text, style)],
ruby: Vec::new(),
para: ParaStyle::default(),
paras: Vec::new(),
area: AreaOptions::default(),
@@ -1259,6 +1529,8 @@ impl TextObject {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
system_layout: false,
fractional_widths: true,
cached_bounds: None,
cached_baselines: Vec::new(),
}
@@ -1266,6 +1538,54 @@ impl TextObject {
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
/// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them.
///
/// Called after loading and editing text: ranges from a file or an edit are untrusted, and a
/// range past the text or an inverted one would lay out nothing (or panic in a silly place).
pub fn normalize_ruby(&mut self) {
let len: usize = self.runs.iter().map(|r| r.text.len()).sum();
for r in &mut self.ruby {
r.start = r.start.min(len);
r.end = r.end.min(len);
if r.start > r.end {
std::mem::swap(&mut r.start, &mut r.end);
}
r.text = r.text.trim().to_string();
}
self.ruby.retain(|r| r.start < r.end && !r.text.is_empty());
self.ruby.sort_by_key(|r| (r.start, r.end));
}
/// The ruby span covering byte `byte` of the plain text, if any.
pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> {
self.ruby.iter().find(|r| r.start <= byte && byte < r.end)
}
/// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered.
pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator<Item = &RubySpan> {
let (a, b) = (start.min(end), start.max(end));
self.ruby.iter().filter(move |r| r.start < b && a < r.end)
}
/// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with
/// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into
/// keep their start and end up where the replaced part ended. Call it after every edit of the
/// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word.
pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) {
let (start, end) = (start.min(end), start.max(end));
let delta = inserted as i64 - (end.saturating_sub(start)) as i64;
let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize };
for r in &mut self.ruby {
if r.end <= start {
continue;
}
if r.start >= end {
r.start = shift(r.start);
r.end = shift(r.end);
} else {
r.start = r.start.min(start);
r.end = shift(r.end).max(r.start);
}
}
self.normalize_ruby();
}
/// Number of paragraphs (`\n`-separated; empty text has one).
pub fn paragraph_count(&self) -> usize {
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
@@ -1556,12 +1876,57 @@ impl crate::Document {
}
}
}
/// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches
/// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art
/// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry
/// ranges from outside: a range past the text, an inverted one or an empty reading would lay
/// out nothing. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn fix_ruby(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.normalize_ruby();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
#[test]
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default());
t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }];
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("かんじ"));
let back: TextObject = serde_json::from_str(&json).unwrap();
assert_eq!(back.ruby, t.ruby);
assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang);
// Old files have no `ruby` key: none.
let mut old: serde_json::Value = serde_json::from_str(&json).unwrap();
old.as_object_mut().unwrap().remove("ruby");
assert!(serde_json::from_value::<TextObject>(old).unwrap().ruby.is_empty());
// A damaged file's range past the text and its empty reading are clamped away.
let mut damaged = t.clone();
damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")];
damaged.normalize_ruby();
assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6));
}
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
/// are saved only when set.
#[test]
@@ -1585,6 +1950,37 @@ mod tests {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
/// Maximum out of order go back to their defaults.
#[test]
fn justification_options_read_from_a_file_stay_in_range() {
let d = JustificationOptions::default();
let read: ParaStyle = serde_json::from_value(serde_json::json!({
"justification": {
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
}
}))
.unwrap();
let j = read.justification;
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
assert_eq!(j.auto_leading, 0.0);
let mut j = JustificationOptions {
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
auto_leading: f64::NAN,
..d.clone()
};
j.keep_in_range();
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
let mut same = d.clone();
same.keep_in_range();
assert_eq!(same, d);
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
+49
View File
@@ -89,4 +89,53 @@ mod tests {
g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
assert_eq!(g.trace_view(), TraceView::Result);
}
#[test]
fn live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle() {
use crate::{Appearance, Document, ImageObject, NodeId, NodeKind};
use std::sync::Arc;
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath};
let mut source = Node::new(
NodeId(2),
NodeKind::Image(Box::new(ImageObject {
key: "synthetic".into(),
width: 200,
height: 200,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
})),
);
source.visible = false;
let art = Node::path(
NodeId(3),
PathData::single(SubPath::polyline(
&[Point::new(20.0, 30.0), Point::new(80.0, 30.0), Point::new(80.0, 90.0), Point::new(20.0, 90.0)],
true,
)),
Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art.clone())]);
group.extras_mut().trace = Some(Box::new(serde_json::json!({})));
let doc = Document::new(300.0, 300.0);
for view in TraceView::ALL {
group.extras_mut().trace = Some(Box::new(serde_json::json!({"view":view.id()})));
let want = if view.shows_source() { Rect::new(0.0, 0.0, 200.0, 200.0) } else { Rect::new(20.0, 30.0, 80.0, 90.0) };
assert_eq!(group.geometric_bounds(), Some(want));
assert_eq!(group.visual_bounds(), Some(want));
if !view.shows_source() {
for angle in [0.0_f64, 30.0, 90.0] {
let xf = Affine::rotate(angle.to_radians());
for visual in [false, true] {
assert_eq!(group.bounds_in(&doc.symbol_boxes(), xf, visual), art.bounds_in(&doc.symbol_boxes(), xf, visual));
}
}
}
}
group.extras_mut().trace = None;
assert_eq!(
group.geometric_bounds(),
Some(Rect::new(0.0, 0.0, 200.0, 200.0)),
"ordinary hidden group members retain their existing bounds policy"
);
}
}
+9 -1
View File
@@ -31,8 +31,12 @@ pub fn to_object_name(id: &str) -> String {
/// since a bare `_` would read back as a space.
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
let last = name.chars().count().saturating_sub(1);
for (i, c) in name.chars().enumerate() {
if c == ' ' {
if c == ' ' && i == last {
// A trailing bare `_` could read back as the end of a uniqueness suffix.
out.push_str("_x20_");
} else if c == ' ' {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
@@ -115,6 +119,10 @@ mod tests {
let id = from_object_name(name);
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
}
let name = "Scan 12345678901234567890 ";
let id = from_object_name(name);
assert!(id.ends_with("_x20_"), "{id:?}");
assert_eq!(to_object_name(&id), name);
assert_eq!(from_object_name("-abc"), "_x2D_abc");
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
assert_eq!(from_object_name("²x"), "_xB2_x");
+19 -5
View File
@@ -48,15 +48,29 @@ enum Channel {
impl Channel {
fn of(p: &Value) -> Self {
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
"red" | "r" => Channel::One(0),
"green" | "g" => Channel::One(1),
"blue" | "b" => Channel::One(2),
_ => Channel::All,
Self::parse(text(p, "channel", "rgb")).unwrap_or(Channel::All)
}
fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"rgb" => Some(Channel::All),
"red" | "r" => Some(Channel::One(0)),
"green" | "g" => Some(Channel::One(1)),
"blue" | "b" => Some(Channel::One(2)),
_ => None,
}
}
}
/// The channels Curves and Levels apply to (`channel`), as their parameters list them.
pub const CHANNELS: [&str; 4] = ["rgb", "red", "green", "blue"];
/// Do Curves and Levels read `s` as their `channel`: one of [`CHANNELS`], or r, g or b, in any
/// case? They draw any other value on all three channels, as RGB.
pub fn is_channel(s: &str) -> bool {
Channel::parse(s).is_some()
}
/// Entries of a sampled tone curve.
const LUT: usize = 256;
+10 -7
View File
@@ -107,9 +107,10 @@ 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, and so is
// type whose own fills or strokes have geometry effects ([`has_item_geometry`]). Type that
// Transform effects only move and copy stays type, moved to each place.
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
// paint a shape ([`has_item_geometry`]). 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() || has_item_geometry(n)) {
if let Some(m) = moved_type_group(n) {
return Some(bake_pieces(d, m));
@@ -150,17 +151,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
bake_leaf(d, n)
}
/// `m`, art evaluated from an object, with its children baked and given ids of their own (the
/// pieces copied from the source keep the source's ids until then).
/// `m`, art evaluated from an object, with its children baked and then given ids of their own (the
/// pieces copied from the source keep the source's ids until then). The pieces are baked with the
/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id.
fn bake_pieces(d: &mut Document, mut m: Node) -> Node {
let mut seen = std::collections::HashSet::from([m.id]);
let children = m.children().cloned().unwrap_or_default();
let children = children
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
let c = Arc::unwrap_or_clone(c);
let mut c = bake_node(d, &c).unwrap_or(c);
fresh_ids(d, &mut c, &mut seen);
Arc::new(bake_node(d, &c).unwrap_or(c))
Arc::new(c)
})
.collect();
if let Some(ch) = m.children_mut() {
+125 -6
View File
@@ -13,12 +13,14 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
("pathfinder.intersect", "Intersect", PathfinderOp::Intersect),
("pathfinder.exclude", "Exclude", PathfinderOp::Exclude),
@@ -29,6 +31,8 @@ pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [
("pathfinder.merge", "Merge", PathfinderOp::Merge),
("pathfinder.crop", "Crop", PathfinderOp::Crop),
("pathfinder.outline", "Outline", PathfinderOp::Outline),
("pathfinder.hardMix", "Hard Mix", PathfinderOp::HardMix),
("pathfinder.softMix", "Soft Mix", PathfinderOp::SoftMix),
];
/// Is `id` an Effect → Pathfinder effect?
@@ -138,15 +142,88 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
if shapes.is_empty() {
return Some(vec![]);
}
stack = po::pathfinder(op, &shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(styled(&sources[r.key as usize], r.path, op == PathfinderOp::Outline)))
.collect();
stack = if op == PathfinderOp::SoftMix {
let rate = mixing_rate(&e.params);
po::regions(&shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(soft_mix_face(r.path, &r.sources, rate, &shapes, &sources)))
.collect()
} else if op == PathfinderOp::HardMix {
po::regions(&shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(hard_mix_face(r.path, &r.sources, &shapes, &sources)))
.collect()
} else {
po::pathfinder(op, &shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(styled(&sources[r.key as usize], r.path, op == PathfinderOp::Outline)))
.collect()
};
}
Some(stack)
}
fn mixing_rate(params: &Value) -> f64 {
params.get("mixingRate").and_then(|v| v.as_f64()).filter(|r| r.is_finite() && *r > 0.0 && *r <= 100.0).map(|r| r / 100.0).unwrap_or(0.5)
}
fn fill_cmyk(src: &Node) -> Option<[f32; 4]> {
let paint = src.appearance.fill_paint();
let color = paint.color()?;
let tinted = match paint {
Paint::Solid { tint, .. } => color.tinted(tint),
_ => color,
};
Some(tinted.to_cmyk())
}
/// One face of a Soft Mix: CMYK blended bottom to top at `rate`; no stroke.
fn soft_mix_face(path: PathData, covering: &[usize], rate: f64, shapes: &[po::Shape], sources: &[Node]) -> Node {
let rate = rate as f32;
let mut cmyk: Option<[f32; 4]> = None;
for &si in covering {
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
cmyk = Some(match cmyk {
None => ch,
Some(acc) => {
let t = rate;
let u = 1.0 - t;
[acc[0] * u + ch[0] * t, acc[1] * u + ch[1] * t, acc[2] * u + ch[2] * t, acc[3] * u + ch[3] * t]
}
});
}
let fill = cmyk.map(|c| Paint::solid(Color::cmyk(c[0], c[1], c[2], c[3]))).unwrap_or(Paint::None);
soft_mix_styled(path, covering, shapes, sources, fill)
}
/// One face of a Hard Mix: the max of each CMYK channel over every covering shape's fill; no stroke.
fn hard_mix_face(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node]) -> Node {
let mut cmyk = [0.0f32; 4];
let mut painted = false;
for &si in covering {
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
for (out, v) in cmyk.iter_mut().zip(ch) {
*out = out.max(v);
}
painted = true;
}
let fill = if painted { Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])) } else { Paint::None };
soft_mix_styled(path, covering, shapes, sources, fill)
}
fn soft_mix_styled(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node], fill: Paint) -> Node {
let top = covering.last().and_then(|&si| sources.get(shapes[si].key as usize));
let mut n = Node::path(top.map_or(NodeId(0), |s| s.id), path, Appearance::basic(fill, Paint::None, 0.0));
if let Some(src) = top {
n.opacity = src.opacity;
n.blend = src.blend;
}
n
}
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
@@ -320,6 +397,48 @@ mod tests {
po::area(n.path_data().unwrap(), FillRule::NonZero).abs()
}
fn overlap_cmyk(g: &Node) -> [f32; 4] {
let faces = pathfinder_children(g, None).unwrap();
assert_eq!(faces.len(), 3);
let overlap = faces
.iter()
.find(|n| {
let b = n.geometric_bounds().unwrap();
b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9
})
.expect("overlap face");
assert!(overlap.appearance.stroke_paint().is_none());
overlap.appearance.fill_paint().color().unwrap().to_cmyk()
}
fn two_overlapping_cmyk_squares() -> Node {
let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0);
let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0);
Node::group(
NodeId(10),
vec![
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)),
Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)),
],
)
}
#[test]
fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() {
let mut g = two_overlapping_cmyk_squares();
g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true });
let [c, m, y, k] = overlap_cmyk(&g);
assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}");
}
#[test]
fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() {
let mut g = two_overlapping_cmyk_squares();
g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true });
let [c, m, y, k] = overlap_cmyk(&g);
assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}");
}
#[test]
fn shape_modes_follow_the_pathfinder_panel() {
let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap();
+221 -12
View File
@@ -20,6 +20,8 @@
//! art painting its members (its raster effects then apply to the composite).
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers.
//! - [`preview`] measures objects as they are drawn, with their geometry effects (Use Preview
//! Bounds).
//! - **Plug-in effects** (`plugin.<plug-in id>`, Effect › Plug-ins) are geometry effects run by an
//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like
//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is.
@@ -38,9 +40,11 @@ mod clip;
mod distort;
mod group;
mod lighting;
mod lighting_presets;
mod live;
mod marks;
pub mod pixel;
pub mod preview;
mod raster;
mod reshape;
mod revolve;
@@ -56,25 +60,29 @@ mod tests;
mod tests_scale;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
pub use adjust::{
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
is_channel,
};
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
pathfinder_children,
};
pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset};
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
view_options,
validate_three_d, view_options,
};
pub use warp::{WarpStyle, warp_point};
@@ -140,6 +148,7 @@ fn lengths_of(id: &str) -> Lengths {
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
"brushStrokes.accentedEdges" => always(&["edgeWidth"]),
"artistic.paletteKnife" => always(&["strokeSize"]),
"brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]),
"brushStrokes.spatter" => always(&["sprayRadius"]),
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
@@ -150,7 +159,11 @@ fn lengths_of(id: &str) -> Lengths {
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
"sketch.graphicPen" => always(&["strokeLength"]),
"sketch.halftonePattern" => always(&["size"]),
"sketch.notePaper" => always(&["relief"]),
"sketch.tornEdges" => always(&["smoothness"]),
"sketch.waterPaper" => always(&["fiberLength"]),
RASTERIZE => always(&["addAround"]),
_ => Lengths::default(),
}
@@ -338,6 +351,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
r(
"artistic.coloredPencil",
"Colored Pencil…",
ARTISTIC,
"{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}),
),
r(
"artistic.cutout",
"Cutout…",
@@ -345,6 +365,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
),
r(
"artistic.dryBrush",
"Dry Brush…",
ARTISTIC,
"{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}),
),
r(
"artistic.filmGrain",
"Film Grain…",
@@ -352,6 +379,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
),
r(
"artistic.fresco",
"Fresco…",
ARTISTIC,
"{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}),
),
r(
"artistic.neonGlow",
"Neon Glow…",
@@ -359,6 +393,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
),
r(
"artistic.paintDaubs",
"Paint Daubs…",
ARTISTIC,
"{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}),
),
r(
"artistic.paletteKnife",
"Palette Knife…",
ARTISTIC,
"{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}),
),
r(
"artistic.plasticWrap",
"Plastic Wrap…",
ARTISTIC,
"{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}),
),
r(
"artistic.posterEdges",
"Poster Edges…",
@@ -366,6 +421,57 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
),
r(
"artistic.roughPastels",
"Rough Pastels…",
ARTISTIC,
"{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.",
json!({
"strokeLength": 6.0,
"strokeDetail": 10.0,
"texture": "canvas",
"scaling": 100.0,
"relief": 4.0,
"lightDirection": "top",
"invert": false
}),
),
r(
"artistic.smudgeStick",
"Smudge Stick…",
ARTISTIC,
"{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}),
),
r(
"artistic.sponge",
"Sponge…",
ARTISTIC,
"{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}),
),
r(
"artistic.underpainting",
"Underpainting…",
ARTISTIC,
"{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.",
json!({
"brushSize": 6.0,
"textureCoverage": 16.0,
"texture": "canvas",
"scaling": 100.0,
"relief": 4.0,
"lightDirection": "top",
"invert": false
}),
),
r(
"artistic.watercolor",
"Watercolor…",
ARTISTIC,
"{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}),
),
r(
"blur.radial",
"Radial Blur…",
@@ -464,13 +570,97 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
),
r(
"sketch.basRelief",
"Bas Relief…",
SKETCH,
"{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}),
),
r(
"sketch.chalkCharcoal",
"Chalk & Charcoal…",
SKETCH,
"{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.",
json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}),
),
r(
"sketch.charcoal",
"Charcoal…",
SKETCH,
"{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}),
),
r(
"sketch.chrome",
"Chrome…",
SKETCH,
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 4.0, "smoothness": 4.0}),
),
r(
"sketch.graphicPen",
"Graphic Pen…",
SKETCH,
"{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.",
json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}),
),
r(
"sketch.halftonePattern",
"Halftone Pattern…",
SKETCH,
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
),
r(
"sketch.notePaper",
"Note Paper…",
SKETCH,
"{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}),
),
r(
"sketch.photocopy",
"Photocopy…",
SKETCH,
"{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 7.0, "darkness": 25.0}),
),
r(
"sketch.plaster",
"Plaster…",
SKETCH,
"{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}),
),
r(
"sketch.reticulation",
"Reticulation…",
SKETCH,
"{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}),
),
r(
"sketch.stamp",
"Stamp…",
SKETCH,
"{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"lightDarkBalance": 25.0, "smoothness": 5.0}),
),
r(
"sketch.tornEdges",
"Torn Edges…",
SKETCH,
"{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}),
),
r(
"sketch.waterPaper",
"Water Paper…",
SKETCH,
"{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.",
json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}),
),
r(
"stylize.glowingEdges",
"Glowing Edges…",
@@ -545,7 +735,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"texture.texturizer",
"Texturizer…",
TEXTURE,
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas 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), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. 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!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
),
r(
@@ -628,7 +818,17 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
}
for (id, label, _) in PATHFINDER_EFFECTS {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
if id == "pathfinder.softMix" {
v.push(g(
id,
label,
PATHFINDER,
"{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)",
json!({"mixingRate": 50.0}),
));
} else {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
}
}
v.push(g(
CROP_MARKS,
@@ -838,6 +1038,12 @@ pub struct GeomContext<'a> {
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
/// way and keeps its result on every redraw.
pub seed: u64,
/// The object's appearance, which live-effect plug-ins are given read only (their fills and
/// strokes).
pub appearance: Option<&'a Appearance>,
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
/// the object's effects.
pub item: Option<usize>,
}
impl<'a> GeomContext<'a> {
@@ -847,15 +1053,17 @@ impl<'a> GeomContext<'a> {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
/// stroke's own effects outline that stroke.
/// stroke's own effects outline that stroke, and a plug-in is told which item it is on.
pub fn item(self, item: &'a AppearanceItem) -> Self {
let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item)));
match item {
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
AppearanceItem::Fill(_) => self,
AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self },
AppearanceItem::Fill(_) => Self { item: at, ..self },
}
}
}
@@ -894,7 +1102,8 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
+112
View File
@@ -0,0 +1,112 @@
//! Original lighting rigs for the publicly documented 3D preset names.
//! Adobe publishes the names, not numeric settings. These values are our own; Standard
//! retains the existing default. A preset replaces the light rig and ambient only.
use serde_json::{Value, json};
use vectorcraft_three_d::{DEFAULT_LIGHT, Light};
#[derive(Clone, Copy, Debug)]
pub struct LightingPreset {
pub id: &'static str,
pub label: &'static str,
rotation: f64,
height: f64,
softness: f64,
ambient: f64,
}
pub const LIGHTING_PRESETS: [LightingPreset; 4] = [
LightingPreset { id: "standard", label: "Standard", rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, softness: 0.0, ambient: 25.0 },
LightingPreset { id: "diffuse", label: "Diffuse", rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, softness: 100.0, ambient: 35.0 },
LightingPreset { id: "topLeft", label: "Top Left", rotation: 135.0, height: 45.0, softness: 0.0, ambient: 25.0 },
LightingPreset { id: "right", label: "Right", rotation: 0.0, height: 30.0, softness: 0.0, ambient: 25.0 },
];
impl LightingPreset {
/// The same patch is used by commands and the dialog's preview transaction. Null
/// `lights` replaces an explicit multi-light rig with this single white light.
pub fn params(self) -> Value {
json!({
"lights": null, "lightColor": "#ffffff", "lightIntensity": 80.0,
"lightRotation": self.rotation, "lightHeight": self.height,
"lightSoftness": self.softness, "lightBehind": false, "ambient": self.ambient,
})
}
fn matches(self, light: Light, ambient: f64) -> bool {
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
!light.behind
&& light.color.iter().all(|c| close(*c, 1.0))
&& close(light.intensity, 0.8)
&& close(light.rotation, self.rotation)
&& close(light.height, self.height)
&& close(light.softness, self.softness / 100.0)
&& close(ambient, self.ambient / 100.0)
}
}
pub fn lighting_preset_params(id: &str) -> Option<Value> {
LIGHTING_PRESETS.iter().find(|p| p.id == id).map(|p| p.params())
}
/// Identify the effective rig, including an equivalent explicit single light. Names
/// are UI state derived from settings, so edits and older files cannot leave stale names.
pub fn matching_lighting_preset(params: &Value) -> Option<usize> {
let upgraded = crate::upgrade_params(crate::REVOLVE, params);
let o = crate::revolve_options(&upgraded);
let light = if let Some(lights) = &o.lights {
let mut entries = lights.iter();
let light = *entries.next()?;
if entries.next().is_some() {
return None;
}
light
} else {
Light {
rotation: o.light_rotation,
height: o.light_height,
behind: o.light_behind,
color: o.light_color,
intensity: o.light_intensity,
softness: o.light_softness,
}
};
LIGHTING_PRESETS.iter().position(|preset| preset.matches(light, o.ambient))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lighting_presets_are_valid_unique_and_standard_preserves_the_default() {
for (i, preset) in LIGHTING_PRESETS.iter().enumerate() {
let patch = preset.params();
crate::lighting::validate(&patch).unwrap();
assert_eq!(lighting_preset_params(preset.id), Some(patch.clone()));
assert_eq!(matching_lighting_preset(&patch), Some(i));
assert!(!patch.as_object().unwrap().contains_key("shade"));
assert!(!patch.as_object().unwrap().contains_key("rotationX"));
}
assert_eq!(matching_lighting_preset(&json!({})), Some(0));
assert!(lighting_preset_params("unknown").is_none());
assert_eq!(crate::revolve_options(&LIGHTING_PRESETS[0].params()).light_direction(), crate::revolve_options(&json!({})).light_direction());
}
#[test]
fn lighting_preset_matching_uses_effective_lights_and_detects_custom_edits() {
let mut p = LIGHTING_PRESETS[3].params();
p["lightColor"] = json!("#ff0000");
p["lights"] = json!([{"rotation":0,"height":30}]);
assert_eq!(matching_lighting_preset(&p), Some(3), "explicit light overrides stale legacy color");
p["lights"][0]["color"] = json!("#4080ff");
assert_eq!(matching_lighting_preset(&p), None);
p["lights"] = json!([{}, {}]);
assert_eq!(matching_lighting_preset(&p), None);
for (key, value) in [("ambient", json!(26)), ("lightIntensity", json!(79)), ("lightBehind", json!(true))] {
let mut p = LIGHTING_PRESETS[0].params();
p[key] = value;
assert_eq!(matching_lighting_preset(&p), None, "{key}");
}
}
}
+611 -5
View File
@@ -1,16 +1,78 @@
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from
//! Effect › Artistic (Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster
//! Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor). Original filters built from
//! public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
//! These are visual approximations, not reproductions of a proprietary algorithm.
use std::f64::consts::TAU;
use super::brushstrokes::{LEFT, RIGHT, Smear, TONE_SOFTNESS, mix, streak, stroke_reach, unit, unpremultiply};
use super::edges::sobel;
use super::pixelate::{Place, cell, rand};
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, blur_plane};
use vectorcraft_geom::Point;
use super::pixelate::{Around, Place, Soft, cell, point_in, rand, value_noise};
use super::texture::{self, Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer};
use super::{PixelSpace, blur_plane, pick};
use vectorcraft_geom::{Point, Vec2};
const SALT_DRY: u64 = 264;
const MARGIN: f64 = 1.0;
const SALT_PLASTIC: u64 = 512;
const SALT_PASTEL: u64 = 200;
const SALT_FILM: u64 = 256;
const SALT_DAUB: u64 = 272;
/// Paint Daubs' brush types, in the Brush Type menu's order: (label, parameter value).
/// Illustrator's list: <https://helpx.adobe.com/illustrator/using/filter-effects.html#paint_daubs>.
pub const PAINT_DAUB_BRUSH_TYPES: [(&str, &str); 6] = [
("Simple", "simple"),
("Light Rough", "lightRough"),
("Dark Rough", "darkRough"),
("Wide Sharp", "wideSharp"),
("Wide Blurry", "wideBlurry"),
("Sparkle", "sparkle"),
];
/// A Paint Daubs brush ([`PAINT_DAUB_BRUSH_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaintDaubBrush {
Simple,
LightRough,
DarkRough,
WideSharp,
WideBlurry,
Sparkle,
}
impl PaintDaubBrush {
const ALL: [PaintDaubBrush; 6] = [
PaintDaubBrush::Simple,
PaintDaubBrush::LightRough,
PaintDaubBrush::DarkRough,
PaintDaubBrush::WideSharp,
PaintDaubBrush::WideBlurry,
PaintDaubBrush::Sparkle,
];
/// The brush of Brush Type `value` (a [`PAINT_DAUB_BRUSH_TYPES`] value, any case); Simple otherwise.
pub fn parse(value: &str) -> Self {
if value.eq_ignore_ascii_case("wideRough") {
return PaintDaubBrush::WideSharp;
}
if value.eq_ignore_ascii_case("hue") {
return PaintDaubBrush::WideBlurry;
}
pick(&PAINT_DAUB_BRUSH_TYPES, &Self::ALL, value, PaintDaubBrush::Simple)
}
}
/// How far (document units) a dab reaches past its centre at `size` (1..50).
pub(super) fn paint_daub_radius(size: f64) -> f64 {
size.clamp(1.0, 50.0) * 0.48
}
const SALT_PENCIL: u64 = 304;
const SALT_FRESCO: u64 = 320;
/// Keep the last cell's four corner values: magnified adjacent pixels often share a cell.
/// The cache changes only cost, never the field, and needs constant storage per raster.
@@ -159,6 +221,55 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
}
/// How far (document units) Colored Pencil reaches at pencil width `width`: soften at
/// `0.12 * width` and the Sobel neighbourhood around it.
pub(super) fn colored_pencil_reach(width: f64) -> f64 {
3.0 * 0.12 * width.clamp(1.0, 24.0) + 1.0
}
/// Colored Pencil: the object redrawn with coloured pencil strokes along local edges, keeping its
/// hues. `width` (1..24) sets the stroke spacing in document units; `pressure` (0..15) darkens
/// along the strokes; `paper` (0..50) lifts lighter areas toward the paper. Coverage is kept.
pub(super) fn colored_pencil(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, paper: f64) {
if !([width, pressure, paper].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let width = width.clamp(1.0, 24.0);
let pressure = (pressure / 15.0).clamp(0.0, 1.0) as f32;
let paper_lift = (paper / 50.0).clamp(0.0, 1.0) as f32;
if pressure == 0.0 && paper_lift == 0.0 {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let (mut colour, alpha) = planes(px);
soften(&mut colour, &alpha, w, h, (0.12 * width) / space.px);
let tones: Vec<f32> = (0..px.len()).map(|k| luma([colour[0][k], colour[1][k], colour[2][k]])).collect();
let mut edges = vec![0.0f32; px.len()];
for (e, s) in edges.iter_mut().zip(sobel(&tones, w, h)) {
*e = (s / 4.0).min(1.0);
}
let spacing = (0.35 * width).max(1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let k = y * w + x;
let c = [colour[0][k], colour[1][k], colour[2][k]];
let gx = tone_at(&tones, w, h, x as isize + 1, y as isize) - tone_at(&tones, w, h, x as isize - 1, y as isize);
let gy = tone_at(&tones, w, h, x as isize, y as isize + 1) - tone_at(&tones, w, h, x as isize, y as isize - 1);
let angle = gy.atan2(gx) + std::f32::consts::FRAC_PI_2;
let (dx, dy) = (angle.cos() as f64, angle.sin() as f64);
let q = place.at(x, y);
let along = q.x * dx + q.y * dy;
let grain = value_noise(SALT_PENCIL, along / spacing, (q.x * 0.37 + q.y * 0.11) / spacing);
let stroke = (0.5 + 0.5 * (along / spacing * TAU + grain * 3.0).sin()) as f32;
let ink = pressure * (0.3 + 0.55 * stroke) * (0.55 + 0.45 * edges[k]);
let lift = paper_lift * (1.0 - luma(c)) * 0.35;
let c = c.map(|v| (v * (1.0 - ink * 0.45) + lift * (1.0 - v)).clamp(0.0, 1.0));
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
@@ -239,11 +350,347 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
}
}
/// Paint Daubs: thick round dabs on a grid `size` (1..50) points apart, each dab painted in the
/// local colour around its centre, with edges as sharp as `sharpness` (0..40) and a profile from
/// `kind`. Coverage is kept.
pub(super) fn paint_daubs(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, sharpness: f64, kind: PaintDaubBrush) {
if !([size, sharpness].iter().all(|v| v.is_finite()) && size > 0.0) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let spacing = size.clamp(1.0, 50.0);
let brush_r = paint_daub_radius(size);
let edge = 1.0 + 2.5 * (sharpness.clamp(0.0, 40.0) / 40.0);
let soft = Soft::new(px, w, (spacing * 0.15) / place.px);
let dab = |i: i64, j: i64| {
let at = point_in(SALT_DAUB, i, j);
let center = Point::new(at.x * spacing, at.y * spacing);
(center, soft.at(place.index(center)))
};
let cover = |q: Point, center: Point, colour: [f32; 4]| -> f32 {
if colour[3] <= 0.0 {
return 0.0;
}
let (dx, dy) = (q.x - center.x, q.y - center.y);
let (dx, dy) = match kind {
PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry => (dx * 0.65, dy * 1.35),
_ => (dx, dy),
};
let t = (dx.hypot(dy) / brush_r).clamp(0.0, 1.0);
let edge_k = match kind {
PaintDaubBrush::WideSharp => edge * 1.35,
PaintDaubBrush::WideBlurry => edge * 0.55,
_ => edge,
};
let mut c = (1.0 - t).powf(edge_k) as f32;
let tone = luma(unpremultiply(colour).0);
match kind {
PaintDaubBrush::LightRough => c *= 0.7 + 0.35 * rough_bristle(q, 0),
PaintDaubBrush::DarkRough => c *= 0.55 + 0.45 * (1.0 - rough_bristle(q, 1)),
PaintDaubBrush::Sparkle if rough_bristle(center, 2) > 0.92 && tone > 0.55 => c = c.max(0.85),
PaintDaubBrush::Simple | PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry | PaintDaubBrush::Sparkle => {}
}
c
};
let mut around = Around::new();
place.each(px, |x, y, out| {
if out[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / spacing, q.y / spacing);
let (Some(i), Some(j)) = (cell(u), cell(v)) else { return };
let mut sum = [0.0f32; 3];
let mut weight = 0.0f32;
for k in 0..9 {
let Some((center, colour)) = around.get((i, j), k, dab) else { continue };
let w = cover(q, center, colour);
if w <= 0.0 {
continue;
}
let c = unpremultiply(colour).0;
for (s, v) in sum.iter_mut().zip(c) {
*s += v * w;
}
weight += w;
}
if weight <= 1e-6 {
return;
}
let c = sum.map(|v| (v / weight).clamp(0.0, 1.0));
*out = premultiply(c, f32::from(out[3]) / 255.0);
});
}
fn rough_bristle(p: Point, k: u64) -> f32 {
let (Some(i), Some(j)) = (cell(p.x * 3.7), cell(p.y * 3.7)) else { return 0.5 };
rand(SALT_DAUB + 3, i, j, k) as f32
}
/// Fresco: coarse brush dabs on a plaster wall. `brush_size` (1..10) sets dab spacing,
/// `brush_detail` (0..10) keeps sharper edges when high, and `texture` (1..3) deepens the
/// relief. Coverage is kept.
pub(super) fn fresco(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush_size: f64, brush_detail: f64, texture: f64) {
if !([brush_size, brush_detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let brush_size = brush_size.clamp(1.0, 10.0);
let brush_detail = brush_detail.clamp(0.0, 10.0);
let texture = texture.round().clamp(1.0, 3.0);
let side = 2.0 + 1.8 * brush_size;
let sigma = (0.15 * (10.0 - brush_detail) * brush_size / place.px).max(0.0);
let soft = Soft::new(px, w, sigma);
let tex_strength = 0.08 + 0.07 * (texture - 1.0);
let tex_scale = 3.0 / texture;
let streak_mix = 0.25 * (1.0 - brush_detail / 10.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
let jx = (rand(SALT_FRESCO, i, j, 0) - 0.5) * 0.35 * side;
let jy = (rand(SALT_FRESCO, i, j, 1) - 0.5) * 0.35 * side;
let sample = Point::new(q.x + jx, q.y + jy);
let mut sampled = soft.at(place.index(sample));
if sampled[3] <= 0.0 {
sampled = soft.at(place.index(q));
}
if sampled[3] <= 0.0 {
return;
}
let [r, g, b, alpha] = sampled;
let c = [r, g, b].map(|v| v / alpha);
let angle = TAU * rand(SALT_FRESCO, i, j, 2);
let (fx, fy) = (q.x - i as f64 * side, q.y - j as f64 * side);
let along = fx * angle.cos() + fy * angle.sin();
let streak = (1.0 - streak_mix * (0.5 + 0.5 * (along / side).sin().powi(2))) as f32;
let bump = (1.0 + tex_strength * smooth_noise(SALT_FRESCO + 3, q, tex_scale * side)) as f32;
let lit = c.map(|v| (v * streak * bump).clamp(0.0, 1.0));
*p = premultiply(lit, f32::from(p[3]) / 255.0);
});
}
/// How far (document units) a Fresco dab can reach from a pixel.
pub(super) fn fresco_reach(brush_size: f64, brush_detail: f64) -> f64 {
let brush_size = brush_size.clamp(1.0, 10.0);
let brush_detail = brush_detail.clamp(0.0, 10.0);
let side = 2.0 + 1.8 * brush_size;
side + 0.15 * (10.0 - brush_detail) * brush_size
}
/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15).
pub(super) fn plastic_reach(smoothness: f64) -> f64 {
3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0))
}
/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds
/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights
/// as strong as `highlight` (0..20) are screened over it. Coverage is kept.
pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) {
if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 {
return;
}
let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32;
if strength == 0.0 {
return;
}
let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32;
let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px;
let Some(place) = Place::new(space, w, h) else { return };
let len = px.len();
let mut height = vec![0.0f32; len];
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64);
let mut noise = CellNoise::default();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let i = y * w + x;
let l = luma(straight(*p));
let q = place.at(x, y);
let nq = Point::new(q.x / cell_scale, q.y / cell_scale);
height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32;
});
for (h, a) in height.iter_mut().zip(&alpha) {
*h *= *a;
}
blur_plane(&mut height, w, h, sigma);
let mut cov = alpha.clone();
blur_plane(&mut cov, w, h, sigma);
for (h, c) in height.iter_mut().zip(&cov) {
*h = if *c > 1e-4 { *h / *c } else { 0.0 };
}
const LIGHT: [f32; 3] = [0.35, -0.25, 0.9];
let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6);
let light = LIGHT.map(|v| v / inv);
let scale = (8.0 * space.px).max(1e-6) as f32;
let sample = |x: isize, y: isize| -> f32 {
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
return 0.0;
}
height[y as usize * w + x as usize]
};
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize);
let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1);
let nx = -gx / scale;
let ny = -gy / scale;
let nz = 1.0;
let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0);
let spec = ndotl.powi(12) * strength;
if spec <= 1e-5 {
return;
}
let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec));
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Stroke length (document units) for Dry Brush at Brush Size `size` (1..10).
fn dry_brush_length(size: f64) -> f64 {
2.0 + 2.8 * (size.clamp(1.0, 10.0) - 1.0)
}
/// How much Dry Brush softens before smearing at Brush Detail `detail` (0..10; 10 keeps the most detail).
fn dry_brush_sigma(detail: f64) -> f64 {
0.22 * (10.0 - detail.clamp(0.0, 10.0))
}
/// How far (document units) Dry Brush reads from a pixel.
pub(super) fn dry_brush_reach(size: f64, detail: f64) -> f64 {
stroke_reach(dry_brush_length(size)) + 3.0 * dry_brush_sigma(detail) + MARGIN
}
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
pub(super) fn glow_spread(size: f64) -> f64 {
0.25 * size.abs().min(24.0)
}
/// Underpainting: broad brush strokes (`brush_size`, 0..40) over a relief `texture` surface
/// (`scaling`, `relief`, `light`, `invert`, as Texturizer uses), blended in as strongly as
/// `texture_coverage` (0..40) says. Coverage is kept.
#[allow(clippy::too_many_arguments)]
pub(super) fn underpainting(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
brush_size: f64,
texture_coverage: f64,
texture: &Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
if !(brush_size.is_finite() && texture_coverage.is_finite() && space.px.is_finite() && space.px > 0.0) {
return;
}
let brush_size = brush_size.clamp(0.0, 40.0);
let coverage = (texture_coverage / 40.0).clamp(0.0, 1.0) as f32;
let (mut colour, alpha) = planes(px);
let sigma = (0.1 * brush_size.max(0.25)) / space.px;
if sigma > 0.0 {
soften(&mut colour, &alpha, w, h, sigma);
}
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let c = [colour[0][k], colour[1][k], colour[2][k]];
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
if coverage <= 0.0 {
return;
}
let softened = px.to_vec();
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert);
if coverage >= 1.0 {
return;
}
let mix = 1.0 - coverage;
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let a = f32::from(p[3]) / 255.0;
let tex = straight(*p);
let soft = straight(softened[k]);
let c = [
(soft[0] * mix + tex[0] * coverage).clamp(0.0, 1.0),
(soft[1] * mix + tex[1] * coverage).clamp(0.0, 1.0),
(soft[2] * mix + tex[2] * coverage).clamp(0.0, 1.0),
];
*p = premultiply(c, a);
}
}
/// How far (document units) Rough Pastels reaches with strokes `len` long.
pub(super) fn rough_pastels_reach(len: f64) -> f64 {
super::brushstrokes::stroke_reach(len).max(super::brushstrokes::TONE_REACH)
}
/// Rough Pastels: soft diagonal pastel strokes `length` (0..40) long with bristle `detail` (1..20),
/// then the shared Texturizer surface (`texture`, `scaling`, `relief`, `light`, `invert`).
/// Coverage is kept.
#[allow(clippy::too_many_arguments)]
pub(super) fn rough_pastels(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
length: f64,
detail: f64,
texture: &Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
if !([length, detail, scaling, relief].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let length = length.clamp(0.0, 40.0);
let detail = detail.clamp(1.0, 20.0);
if length > 0.0 {
let Some(place) = Place::new(space, w, h) else { return };
let smear = Smear::new(px, w, h, &place, length.max(1.0));
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let row = (0.18 * smear.len).max(1.0);
let stroke_mix = (0.35 + 0.0225 * detail).clamp(0.35, 0.8) as f32;
let bristle = (0.015 + 0.0035 * detail) as f32;
let src = px.to_vec();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let pm = mix(smear.stroke(SALT_PASTEL, q, RIGHT, row), smear.stroke(SALT_PASTEL + 1, q, LEFT, row), 0.5);
let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4]));
let pm = mix(orig, pm, stroke_mix);
let (mut c, a) = unpremultiply(pm);
if a <= 0.0 {
*p = [0; 4];
return;
}
let dir = if tones.get(x, y) > 0.52 { RIGHT } else { LEFT };
let n = bristle * streak(SALT_PASTEL + 2, q, dir, smear.len.max(1.0), row) as f32;
c = c.map(|v| (v + n).clamp(0.0, 1.0));
*p = premultiply(c, orig[3]);
});
}
if relief > 0.0 {
texturizer(px, w, h, space, texture, scaling, relief, light, invert);
}
}
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
/// strong as `brightness` (0..50). Coverage is kept.
@@ -268,3 +715,162 @@ pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
/// The tone at `(x, y)` in `tone`, `w` × `h`, or 0 outside.
fn tone_at(tone: &[f32], w: usize, h: usize, x: isize, y: isize) -> f32 {
if x < 0 || y < 0 {
return 0.0;
}
let (x, y) = (x as usize, y as usize);
if x >= w || y >= h {
return 0.0;
}
tone[y * w + x]
}
/// A unit vector along the image gradient's tangent at `(x, y)`, or horizontal when flat.
fn dry_brush_dir(tone: &[f32], w: usize, h: usize, x: usize, y: usize) -> Vec2 {
let gx = 0.5 * (tone_at(tone, w, h, x as isize + 1, y as isize) - tone_at(tone, w, h, x as isize - 1, y as isize));
let gy = 0.5 * (tone_at(tone, w, h, x as isize, y as isize + 1) - tone_at(tone, w, h, x as isize, y as isize - 1));
let v = Vec2::new(-f64::from(gy), f64::from(gx));
let len = v.hypot();
if len > 1e-6 { v / len } else { Vec2::new(1.0, 0.0) }
}
/// Premultiplied RGBA (0..1) at document `q`, bilinearly from `src`.
fn sample_pm(src: &[[f32; 4]], w: usize, h: usize, place: &Place, q: Point) -> [f32; 4] {
let p = place.to_px * q;
if !(p.x.is_finite() && p.y.is_finite()) {
return [0.0; 4];
}
let (x, y) = (p.x - 0.5, p.y - 0.5);
let (i, j) = (x.floor() as isize, y.floor() as isize);
let (fx, fy) = (x - i as f64, y - j as f64);
let mut acc = [0.0f32; 4];
for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] {
let Some(row) = usize::try_from(row).ok().filter(|r| *r < h) else { continue };
for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] {
let Some(col) = usize::try_from(col).ok().filter(|c| *c < w) else { continue };
let k = (wx * wy) as f32;
if let Some(p) = src.get(row * w + col) {
for (a, v) in acc.iter_mut().zip(p) {
*a += *v * k;
}
}
}
}
acc
}
/// Dry Brush: the object repainted with short strokes along its form, edges darkened. `size`
/// (1..10) sets the stroke length, `detail` (0..10) how much is softened first (10 keeps the most
/// detail), and `texture` (1..3) how strongly edges and bristle grain contrast. Coverage is kept.
pub(super) fn dry_brush(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, detail: f64, texture: f64) {
if !([size, detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let len = dry_brush_length(size);
let sigma = dry_brush_sigma(detail);
let texture = texture.clamp(1.0, 3.0);
let edge_gain = (0.25 + 0.2 * (texture - 1.0)) as f32;
let bristle = (0.04 + 0.05 * (texture - 1.0)) as f32;
let (mut colour, alpha) = planes(px);
if sigma > 0.0 {
soften(&mut colour, &alpha, w, h, sigma);
}
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
let tone: Vec<f32> = (0..w * h)
.map(|k| {
let c = at(k);
let a = alpha.get(k).copied().unwrap_or(0.0);
(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) * a
})
.collect();
let edges = sobel(&tone, w, h);
let src: Vec<[f32; 4]> = (0..w * h)
.map(|k| {
let c = at(k);
let a = alpha.get(k).copied().unwrap_or(0.0);
[c[0] * a, c[1] * a, c[2] * a, a]
})
.collect();
let Some(place) = Place::new(space, w, h) else { return };
const SAMPLES: usize = 5;
let mut out = px.to_vec();
for (k, p) in out.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let (x, y) = (k % w, k / w);
let q = place.at(x, y);
let dir = dry_brush_dir(&tone, w, h, x, y);
let mut acc = [0.0f32; 4];
for i in 0..SAMPLES {
let t = (i as f64 / (SAMPLES - 1) as f64 - 0.5) * len;
let s = sample_pm(&src, w, h, &place, q + dir * t);
for (a, v) in acc.iter_mut().zip(s) {
*a += v;
}
}
for a in &mut acc {
*a /= SAMPLES as f32;
}
let a = acc[3].clamp(0.0, 1.0);
let c = if a > 1e-6 { [0, 1, 2].map(|i| (acc[i] / a).clamp(0.0, 1.0)) } else { straight(*p) };
let edge = edges[k].min(1.0);
let dark = (edge_gain * edge).clamp(0.0, 0.75);
let grain = bristle * (2.0 * value_noise(SALT_DRY, q.x / (5.0 * space.px), q.y / (5.0 * space.px)) - 1.0) as f32;
let c = c.map(|v| (v * (1.0 - dark) + grain).clamp(0.0, 1.0));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
px.copy_from_slice(&out);
}
const SALT_KNIFE: u64 = 208;
/// How far Palette Knife reaches with strokes `stroke_size` long and Softness `softness` (0..10).
pub(super) fn palette_knife_reach(stroke_size: f64, softness: f64) -> f64 {
stroke_reach(stroke_size) + 3.0 * 0.35 * softness.clamp(0.0, 10.0) + 1.0
}
/// Artistic › Palette Knife: the object repainted with short, angled knife strokes that smear its
/// colours; `stroke_size` (1..50) sets their length, `stroke_detail` (1..3) keeps more of the
/// original where it's higher, and `softness` (0..10) softens the strokes. Coverage is kept.
pub(super) fn palette_knife(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke_size: f64, stroke_detail: f64, softness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(stroke_size.is_finite() && stroke_detail.is_finite() && softness.is_finite()) {
return;
}
let len = stroke_size.clamp(1.0, 50.0);
let keep = ((stroke_detail.clamp(1.0, 3.0) - 1.0) / 2.0) as f32;
let smear = Smear::new(px, w, h, &place, len);
let row = (0.25 * smear.len).max(1.0);
let cell = len * 0.35;
let src = px.to_vec();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let angle = TAU * value_noise(SALT_KNIFE, q.x / cell, q.y / cell);
let dir = Vec2::new(angle.cos(), angle.sin());
let pm = smear.stroke(SALT_KNIFE + 1, q, dir, row);
let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4]));
let coverage = orig[3];
let (c, _) = unpremultiply(mix(orig, pm, 1.0 - keep));
*p = premultiply(c, coverage);
});
let sigma = 0.35 * softness.clamp(0.0, 10.0) / place.px;
if sigma > 0.0 {
let (mut colour, alpha) = planes(px);
let cover = alpha.clone();
soften(&mut colour, &alpha, w, h, sigma);
for (k, p) in px.iter_mut().enumerate() {
if cover[k] == 0.0 {
continue;
}
let c = [colour[0][k], colour[1][k], colour[2][k]];
*p = premultiply(c, cover[k]);
}
}
}
+52 -15
View File
@@ -64,9 +64,9 @@ impl StrokeDirection {
}
/// Along a right diagonal stroke, rising to the right (y down).
const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
pub(super) const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
/// Along a left diagonal stroke, falling to the right.
const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
pub(super) const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
/// The most samples a stroke averages, whatever its length.
const MAX_SAMPLES: usize = 16;
@@ -75,7 +75,7 @@ const MAX_SAMPLES: usize = 16;
const JITTER: f64 = 0.25;
/// How soft (points, the σ) the tones are that decide which strokes a pixel gets.
const TONE_SOFTNESS: f64 = 1.0;
pub(super) const TONE_SOFTNESS: f64 = 1.0;
/// Dark Strokes' short dark strokes and long light ones (points).
pub(super) const DARK_LENGTH: f64 = 3.0;
@@ -86,7 +86,7 @@ const INK_SOFTNESS: f64 = 0.6;
/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling
/// between pixels add to a filter's reach.
const MARGIN: f64 = 1.0;
pub(super) const MARGIN: f64 = 1.0;
/// How far (document units) strokes `len` long reach: half their length, their random shift, the
/// softening between their samples and the pixels a sample is taken between.
@@ -137,18 +137,18 @@ pub(super) fn spray_shift(radius: f64) -> f64 {
/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object
/// softened first when they lie more than a pixel apart.
struct Smear<'a> {
pub(super) struct Smear<'a> {
/// Premultiplied colours (0..1).
src: Vec<[f32; 4]>,
w: usize,
h: usize,
place: &'a Place,
len: f64,
pub(super) len: f64,
n: usize,
}
impl<'a> Smear<'a> {
fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
let span = len / place.px;
let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES };
@@ -218,7 +218,7 @@ impl<'a> Smear<'a> {
}
/// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart.
fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
pub(super) fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
}
}
@@ -231,7 +231,7 @@ fn frame(q: Point, dir: Vec2) -> (f64, f64) {
/// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width`
/// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] ×
/// `len`.
fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
pub(super) fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
if !(len > 0.0 && width > 0.0) {
return 0.0;
}
@@ -242,37 +242,37 @@ fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
}
/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide.
fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
pub(super) fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
let (u, v) = frame(q, dir);
2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0
}
/// The straight colour and coverage of premultiplied `pm` (0..1).
fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
pub(super) fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
let a = pm[3].clamp(0.0, 1.0);
if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) }
}
/// `a` + (`b` − `a`) × `t`, channel by channel.
fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
pub(super) fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t)
}
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1.
fn ramp(t: f64) -> f64 {
pub(super) fn ramp(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
t * t * (3.0 - 2.0 * t)
}
/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1.
fn unit(p: [u8; 4]) -> [f32; 4] {
pub(super) fn unit(p: [u8; 4]) -> [f32; 4] {
p.map(|v| f32::from(v) / 255.0)
}
/// The object's edges after softening it by `sigma` (document units): about 1 across a step from
/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't
/// match.
fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
pub(super) fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let s = sigma / place.px;
@@ -516,3 +516,40 @@ pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
*p = premultiply(out, f32::from(p[3]) / 255.0);
});
}
const SALT_GRAPHIC: u64 = 200;
/// Graphic Pen: the object redrawn in fine linear ink strokes along `dir`, each `len` (1..15)
/// points long on white paper; `balance` (0..100) sets the tone threshold (higher draws more
/// black ink).
pub(super) fn graphic_pen(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, balance: f64, dir: StrokeDirection) {
let Some(place) = Place::new(space, w, h) else { return };
let len = if len.is_finite() { len.clamp(1.0, 15.0) } else { 1.0 };
let smear = Smear::new(px, w, h, &place, len);
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let threshold = 1.0 - balance.clamp(0.0, 100.0) / 100.0;
let row = (0.2 * len).max(0.75);
let along = dir.along();
place.each(px, |x, y, p| {
let a = f32::from(p[3]) / 255.0;
if a <= 0.0 {
return;
}
let q = place.at(x, y);
let (c, stroke_a) = unpremultiply(smear.stroke(SALT_GRAPHIC, q, along, row));
let dark = (1.0 - tones.get(x, y)).max(f64::from(1.0 - luma(c)) * f64::from(stroke_a));
if dark <= threshold {
*p = premultiply([1.0, 1.0, 1.0], a);
return;
}
let weight = ramp((dark - threshold) / (1.0 - threshold).max(1e-3));
let u = frame(q, along).0;
let on_stroke = {
let t = (u / len).rem_euclid(1.0);
if t < 0.65 { 1.0 } else { 0.0 }
};
let grain = 0.08 * streak(SALT_GRAPHIC + 1, q, along, 0.35 * len, 0.5);
let ink = (weight * on_stroke + grain).clamp(0.0, 1.0);
*p = premultiply([(1.0 - ink) as f32; 3], a);
});
}
+352 -14
View File
@@ -1,7 +1,9 @@
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
//! Photoshop-style raster effects (Effect › Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Plastic Wrap and
//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster,
//! Reticulation, Stamp and Torn Edges, Stylize ›
//! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
@@ -19,9 +21,11 @@ mod brushstrokes;
mod distort;
mod edges;
mod loaded;
mod paint;
mod pixelate;
mod sharpen;
mod sketch;
mod sketch_ink;
mod texture;
mod video;
@@ -30,20 +34,32 @@ use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{flag, num, text};
pub use artistic::{PAINT_DAUB_BRUSH_TYPES, PaintDaubBrush};
pub use blur::{SMART_BLUR_MODES, SmartBlurMode};
pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection};
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 sketch::{HALFTONE_TYPES, HalftonePattern};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 32] = [
pub const PIXEL_EFFECTS: [&str; 55] = [
"artistic.coloredPencil",
"artistic.cutout",
"artistic.dryBrush",
"artistic.filmGrain",
"artistic.fresco",
"artistic.neonGlow",
"artistic.paintDaubs",
"artistic.paletteKnife",
"artistic.plasticWrap",
"artistic.posterEdges",
"artistic.roughPastels",
"artistic.smudgeStick",
"artistic.sponge",
"artistic.underpainting",
"artistic.watercolor",
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
@@ -62,7 +78,19 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"sketch.basRelief",
"sketch.chalkCharcoal",
"sketch.charcoal",
"sketch.chrome",
"sketch.graphicPen",
"sketch.halftonePattern",
"sketch.notePaper",
"sketch.photocopy",
"sketch.plaster",
"sketch.reticulation",
"sketch.stamp",
"sketch.tornEdges",
"sketch.waterPaper",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -78,19 +106,57 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
/// are in document units.
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Colored Pencil: coloured strokes `width` (1..24) apart, as heavy as `pressure`
/// (0..15), on paper lifted by `paper` (0..50).
ColoredPencil { width: f64, pressure: f64, paper: f64 },
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
/// Artistic › Dry Brush: short strokes along the form with darkened edges; `size` (1..10) is the
/// brush length, `detail` (0..10) how much detail is kept, `texture` (1..3) the edge and bristle
/// contrast.
DryBrush { size: f64, detail: f64, texture: f64 },
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
/// Artistic › Fresco: coarse brush dabs on plaster; `brush_size` (1..10), `brush_detail`
/// (0..10) and `texture` (1..3).
Fresco { brush_size: f64, brush_detail: f64, texture: f64 },
/// Artistic › Smudge Stick: the dark areas smudged along short diagonal strokes `stroke`
/// (0..10) long, the light areas (widened by `highlight`, 0..20) smudged longer and lightened
/// by up to `intensity` (0..10).
SmudgeStick { stroke: f64, highlight: f64, intensity: f64 },
/// Artistic › Sponge: soft blobs of lighter and darker colour about `brush` (0..10) large,
/// contrasting as `definition` (0..25) says, their edges as soft as `smoothness` (1..15).
Sponge { brush: f64, definition: f64, smoothness: f64 },
/// Artistic › Watercolor: detail simplified by a brush of `detail` (1..14), edges saturated,
/// shadows deepened by `shadow` (0..10), `texture` (1..3) grains of paper showing.
Watercolor { detail: f64, shadow: f64, texture: u32 },
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
/// strong as `brightness` (0..50).
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
/// Artistic › Paint Daubs: dabs `size` (1..50) points apart, as sharp as `sharpness` (0..40),
/// with brush `kind`.
PaintDaubs { size: f64, sharpness: f64, kind: PaintDaubBrush },
/// Artistic › Palette Knife: knife strokes `stroke_size` (1..50) long smear the colours;
/// `stroke_detail` (1..3) keeps more original detail when higher; `softness` (0..10) softens
/// the strokes.
PaletteKnife { stroke_size: f64, stroke_detail: f64, softness: f64 },
/// Artistic › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief
/// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15).
PlasticWrap { highlight: f64, detail: f64, smoothness: f64 },
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
PosterEdges { thickness: f64, intensity: f64, posterization: f64 },
/// Artistic › Underpainting: colours softened as widely as `brush_size` (0..40), then a
/// `texture` surface (`scaling`, `relief`, `light`, `invert`) blended in as strongly as
/// `texture_coverage` (0..40) says.
Underpainting { brush_size: f64, texture_coverage: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Artistic › Rough Pastels: diagonal pastel strokes `length` (0..40) long, bristle detail
/// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as
/// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
RadialBlur { amount: f64, zoom: bool, passes: u32 },
@@ -139,9 +205,46 @@ pub enum PixelFx {
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
/// Sketch › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones;
/// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight.
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
Chrome { detail: f64, smoothness: f64 },
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
/// Sketch › Note Paper: embossed tones on fibrous paper (`balance`, `graininess`, `relief`).
NotePaper { balance: f64, graininess: f64, relief: f64 },
/// Sketch › Bas Relief: low relief lit from `light`, keeping the finer features as `detail`
/// (1..15) says and rounded by `smoothness` (1..15).
BasRelief { detail: f64, smoothness: f64, light: Light },
/// Sketch › Charcoal: bold edges `thickness` (1..7) wide, as fine as `detail` (0..5) says, and
/// diagonal strokes over the midtones; `balance` (0..100) sets how far into the lights it reaches.
Charcoal { thickness: f64, detail: f64, balance: f64 },
/// Sketch › Photocopy: black only where a pixel is darker than its surroundings, as fine as
/// `detail` (1..24) says, and as readily as `darkness` (1..50) says.
Photocopy { detail: f64, darkness: f64 },
/// Sketch › Stamp: solid black and white, printing black as much as `balance` (0..50) says,
/// its shapes rounded by `smoothness` (1..50).
Stamp { balance: f64, smoothness: f64 },
/// Sketch › Plaster: relief plaster lit from `light`; dark areas raised, light recessed;
/// `imageBalance` (1..50) shifts more of the image lower at higher values; `smoothness` (1..15)
/// softens the relief.
Plaster { image_balance: f64, smoothness: f64, light: Light },
/// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen;
/// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain.
Reticulation { density: f64, foreground: f64, background: f64 },
/// Sketch › Torn Edges: high-contrast black and white with ragged edges; `image_balance`
/// (0..50) shifts ink versus paper, `smoothness` (0..15) softens tones and fraying, `contrast`
/// (0..20) hardens the split.
TornEdges { image_balance: f64, smoothness: f64, contrast: f64 },
/// Sketch › Water Paper: colours flow on damp fibrous paper; `fiber_length` (3..50) sets the
/// fibers and daubs, `brightness` and `contrast` (0..100) adjust the wash. Ranges and defaults
/// are unverified VectorCraft choices (helpx documents the controls only).
WaterPaper { fiber_length: f64, brightness: f64, contrast: f64 },
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
@@ -222,16 +325,31 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
};
Some(match id {
"artistic.coloredPencil" => PixelFx::ColoredPencil {
width: num(p, "pencilWidth", 5.0).clamp(1.0, 24.0),
pressure: num(p, "strokePressure", 5.0).clamp(0.0, 15.0),
paper: num(p, "paperBrightness", 25.0).clamp(0.0, 50.0),
},
"artistic.cutout" => PixelFx::Cutout {
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
},
"artistic.dryBrush" => PixelFx::DryBrush {
size: num(p, "brushSize", 2.0).clamp(1.0, 10.0),
detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0),
texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0),
},
"artistic.neonGlow" => PixelFx::NeonGlow {
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(),
},
"artistic.paletteKnife" => PixelFx::PaletteKnife {
stroke_size: num(p, "strokeSize", 10.0).clamp(1.0, 50.0),
stroke_detail: num(p, "strokeDetail", 2.0).clamp(1.0, 3.0),
softness: num(p, "softness", 3.0).clamp(0.0, 10.0),
},
"artistic.posterEdges" => PixelFx::PosterEdges {
thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0),
intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0),
@@ -242,6 +360,54 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
},
"artistic.underpainting" => PixelFx::Underpainting {
brush_size: num(p, "brushSize", 6.0).clamp(0.0, 40.0),
texture_coverage: num(p, "textureCoverage", 16.0).clamp(0.0, 40.0),
texture: Texture::parse(text(p, "texture", "canvas")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
invert: flag(p, "invert", false),
},
"artistic.roughPastels" => PixelFx::RoughPastels {
length: num(p, "strokeLength", 6.0).clamp(0.0, 40.0),
detail: num(p, "strokeDetail", 10.0).clamp(1.0, 20.0),
texture: Texture::parse(text(p, "texture", "canvas")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
invert: flag(p, "invert", false),
},
"artistic.fresco" => PixelFx::Fresco {
brush_size: num(p, "brushSize", 2.0).clamp(1.0, 10.0),
brush_detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0),
texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0),
},
"artistic.smudgeStick" => PixelFx::SmudgeStick {
stroke: num(p, "strokeLength", 2.0).clamp(0.0, 10.0),
highlight: num(p, "highlightArea", 12.0).clamp(0.0, 20.0),
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
},
"artistic.sponge" => PixelFx::Sponge {
brush: num(p, "brushSize", 2.0).clamp(0.0, 10.0),
definition: num(p, "definition", 12.0).clamp(0.0, 25.0),
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
},
"artistic.watercolor" => PixelFx::Watercolor {
detail: num(p, "brushDetail", 9.0).clamp(1.0, 14.0),
shadow: num(p, "shadowIntensity", 1.0).clamp(0.0, 10.0),
texture: num(p, "texture", 1.0).clamp(1.0, 3.0).round() as u32,
},
"artistic.paintDaubs" => PixelFx::PaintDaubs {
size: num(p, "brushSize", 10.0).clamp(1.0, 50.0),
sharpness: num(p, "sharpness", 25.0).clamp(0.0, 40.0),
kind: PaintDaubBrush::parse(text(p, "brushType", "simple")),
},
"artistic.plasticWrap" => PixelFx::PlasticWrap {
highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0),
detail: num(p, "detail", 12.0).clamp(0.0, 15.0),
smoothness: num(p, "smoothness", 15.0).clamp(1.0, 15.0),
},
"blur.radial" => PixelFx::RadialBlur {
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
@@ -311,11 +477,63 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
},
"sketch.chalkCharcoal" => PixelFx::ChalkCharcoal {
chalk: num(p, "chalkArea", 6.0).clamp(0.0, 20.0),
charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0),
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
},
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
"sketch.graphicPen" => PixelFx::GraphicPen {
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
},
"sketch.halftonePattern" => PixelFx::HalftonePattern {
size: num(p, "size", 1.0).clamp(1.0, 12.0),
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
},
"sketch.notePaper" => PixelFx::NotePaper {
balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0),
graininess: num(p, "graininess", 0.0).clamp(0.0, 10.0),
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
},
"sketch.basRelief" => PixelFx::BasRelief {
detail: num(p, "detail", 13.0).clamp(1.0, 15.0),
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
light: Light::parse(text(p, "lightDirection", "bottom")),
},
"sketch.charcoal" => PixelFx::Charcoal {
thickness: num(p, "thickness", 1.0).clamp(1.0, 7.0),
detail: num(p, "detail", 1.0).clamp(0.0, 5.0),
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
},
"sketch.photocopy" => {
PixelFx::Photocopy { detail: num(p, "detail", 7.0).clamp(1.0, 24.0), darkness: num(p, "darkness", 25.0).clamp(1.0, 50.0) }
}
"sketch.stamp" => {
PixelFx::Stamp { balance: num(p, "lightDarkBalance", 25.0).clamp(0.0, 50.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 50.0) }
}
"sketch.plaster" => PixelFx::Plaster {
image_balance: num(p, "imageBalance", 20.0).clamp(1.0, 50.0),
smoothness: num(p, "smoothness", 8.0).clamp(1.0, 15.0),
light: Light::parse(text(p, "lightDirection", "top")),
},
"sketch.reticulation" => PixelFx::Reticulation {
density: num(p, "density", 12.0).clamp(0.0, 50.0),
foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0),
background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0),
},
"sketch.tornEdges" => PixelFx::TornEdges {
image_balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0),
smoothness: num(p, "smoothness", 11.0).clamp(0.0, 15.0),
contrast: num(p, "contrast", 17.0).clamp(0.0, 20.0),
},
"sketch.waterPaper" => PixelFx::WaterPaper {
fiber_length: num(p, "fiberLength", 15.0).clamp(3.0, 50.0),
brightness: num(p, "brightness", 50.0).clamp(0.0, 100.0),
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
},
"stylize.glowingEdges" => PixelFx::GlowingEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
@@ -353,7 +571,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
},
"texture.texturizer" => PixelFx::Texturizer {
texture: Texture::parse(text(p, "texture", "canvas")),
texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
@@ -381,7 +599,11 @@ impl PixelFx {
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::AccentedEdges { .. } => 0.0,
// Strokes reach out of the object as far as they reach into it.
PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length),
PixelFx::PaletteKnife { stroke_size, softness, .. } => artistic::palette_knife_reach(stroke_size, softness),
PixelFx::AngledStrokes { length, .. }
| PixelFx::GraphicPen { length, .. }
| PixelFx::InkOutlines { length, .. }
| PixelFx::RoughPastels { length, .. } => brushstrokes::stroke_reach(length),
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
@@ -392,12 +614,29 @@ impl PixelFx {
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
PixelFx::UnsharpMask { .. }
| PixelFx::ChalkCharcoal { .. }
| PixelFx::Chrome { .. }
| PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. }
| PixelFx::Plaster { .. }
| PixelFx::Reticulation { .. }
| PixelFx::TornEdges { .. } => 0.0,
PixelFx::BasRelief { .. } | PixelFx::Charcoal { .. } | PixelFx::Photocopy { .. } | PixelFx::Stamp { .. } => 0.0,
PixelFx::WaterPaper { fiber_length, .. } => sketch::water_paper_shift(fiber_length),
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::PaintDaubs { size, .. } => artistic::paint_daub_radius(size),
// These three keep the object's coverage.
PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0,
PixelFx::FilmGrain { .. }
| PixelFx::ColoredPencil { .. }
| PixelFx::Fresco { .. }
| PixelFx::Cutout { .. }
| PixelFx::DryBrush { .. }
| PixelFx::NeonGlow { .. }
| PixelFx::PlasticWrap { .. }
| PixelFx::PosterEdges { .. }
| PixelFx::Underpainting { .. }
| PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
@@ -420,9 +659,16 @@ impl PixelFx {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
PixelFx::SmudgeStick { stroke, .. } => Some(paint::smudge_reach(stroke)),
PixelFx::Sponge { brush, smoothness, .. } => Some(paint::sponge_reach(brush, smoothness)),
PixelFx::Watercolor { detail, .. } => Some(paint::watercolor_reach(detail)),
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
// The strokes, and the softened tones that choose them.
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
PixelFx::PaletteKnife { stroke_size, softness, .. } => Some(artistic::palette_knife_reach(stroke_size, softness)),
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } => {
Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH))
}
PixelFx::RoughPastels { length, .. } => Some(artistic::rough_pastels_reach(length)),
PixelFx::Crosshatch { length, .. } => {
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
}
@@ -435,17 +681,37 @@ impl PixelFx {
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
PixelFx::BasRelief { detail, smoothness, .. } => Some(sketch_ink::relief_reach(detail, smoothness)),
PixelFx::Charcoal { thickness, detail, .. } => Some(sketch_ink::charcoal_reach(thickness, detail)),
PixelFx::Photocopy { detail, .. } => Some(sketch_ink::copy_reach(detail)),
PixelFx::Stamp { smoothness, .. } => Some(sketch_ink::stamp_reach(smoothness)),
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
// Cutout's palette comes from the whole object.
PixelFx::Cutout { .. } => None,
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
PixelFx::FilmGrain { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. }
| PixelFx::Reticulation { .. } => Some(0.0),
PixelFx::Fresco { brush_size, brush_detail, .. } => Some(artistic::fresco_reach(brush_size, brush_detail)),
PixelFx::DryBrush { size, detail, .. } => Some(artistic::dry_brush_reach(size, detail)),
PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)),
PixelFx::PaintDaubs { size, .. } => Some(artistic::paint_daub_radius(size) + size * 0.15),
PixelFx::WaterPaper { fiber_length, .. } => {
Some(sketch::water_paper_shift(fiber_length) + 3.0 * (0.06 * fiber_length.clamp(3.0, 50.0)).max(0.35))
}
// Cutout's palette and Torn Edges' peak-normalised edges come from the whole object.
PixelFx::Cutout { .. } | PixelFx::TornEdges { .. } => None,
// The softened tones around the pixel.
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)),
// The edges' lines, spread round them.
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
PixelFx::Underpainting { brush_size, .. } => Some(3.0 * (0.1 * brush_size.max(0.25)) + 1.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -466,12 +732,29 @@ impl PixelFx {
let Some(px) = pixels(data, w, h) else { return };
let to_px = |len: f64| len / space.px.max(1e-9);
match *self {
PixelFx::ColoredPencil { width, pressure, paper } => artistic::colored_pencil(px, w, h, space, width, pressure, paper),
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
PixelFx::DryBrush { size, detail, texture } => artistic::dry_brush(px, w, h, space, size, detail, texture),
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::Fresco { brush_size, brush_detail, texture } => artistic::fresco(px, w, h, space, brush_size, brush_detail, texture),
PixelFx::SmudgeStick { stroke, highlight, intensity } => paint::smudge_stick(px, w, h, space, stroke, highlight, intensity),
PixelFx::Sponge { brush, definition, smoothness } => paint::sponge(px, w, h, space, brush, definition, smoothness),
PixelFx::Watercolor { detail, shadow, texture } => paint::watercolor(px, w, h, space, detail, shadow, texture),
PixelFx::PaintDaubs { size, sharpness, kind } => artistic::paint_daubs(px, w, h, space, size, sharpness, kind),
PixelFx::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness),
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
PixelFx::PaletteKnife { stroke_size, stroke_detail, softness } => {
artistic::palette_knife(px, w, h, space, stroke_size, stroke_detail, softness)
}
PixelFx::PosterEdges { thickness, intensity, posterization } => {
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
}
PixelFx::Underpainting { brush_size, texture_coverage, ref texture, scaling, relief, light, invert } => {
artistic::underpainting(px, w, h, space, brush_size, texture_coverage, texture, scaling, relief, light, invert)
}
PixelFx::RoughPastels { length, detail, ref texture, scaling, relief, light, invert } => {
artistic::rough_pastels(px, w, h, space, length, detail, texture, scaling, relief, light, invert)
}
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
blur::smart(px, w, h, to_px(radius), threshold, samples)
@@ -493,7 +776,19 @@ impl PixelFx {
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
PixelFx::BasRelief { detail, smoothness, light } => sketch_ink::bas_relief(px, w, h, space, detail, smoothness, light),
PixelFx::Charcoal { thickness, detail, balance } => sketch_ink::charcoal(px, w, h, space, thickness, detail, balance),
PixelFx::Photocopy { detail, darkness } => sketch_ink::photocopy(px, w, h, space, detail, darkness),
PixelFx::Stamp { balance, smoothness } => sketch_ink::stamp(px, w, h, space, balance, smoothness),
PixelFx::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light),
PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background),
PixelFx::TornEdges { image_balance, smoothness, contrast } => sketch::torn_edges(px, w, h, space, image_balance, smoothness, contrast),
PixelFx::WaterPaper { fiber_length, brightness, contrast } => sketch::water_paper(px, w, h, space, fiber_length, brightness, contrast),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
@@ -504,7 +799,7 @@ impl PixelFx {
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
PixelFx::Texturizer { ref texture, scaling, relief, light, invert } => {
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
}
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
@@ -594,6 +889,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
}
}
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
/// less, or when the sizes don't match.
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
return;
}
let radius = radius.min(MAX_BOX as f64);
let whole = radius.floor() as usize;
let frac = (radius - whole as f64) as f32;
let mut tmp = vec![0.0; a.len()];
for (len, other) in [(w, h), (h, w)] {
max_plane(a, &mut tmp, len, whole, frac);
std::mem::swap(a, &mut tmp);
transpose(a, &mut tmp, len, other);
std::mem::swap(a, &mut tmp);
}
}
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
for (x, o) in out.iter_mut().enumerate() {
let lo = x.saturating_sub(whole + 1);
let hi = (x + whole + 1).min(w - 1);
let (mut inner, mut ring) = (row[x], row[x]);
for (j, v) in row[lo..=hi].iter().enumerate() {
if (lo + j).abs_diff(x) <= whole {
inner = inner.max(*v);
}
ring = ring.max(*v);
}
*o = inner + (ring - inner) * frac;
}
}
}
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
pub(super) type Px16 = [u16; 4];
@@ -703,3 +1037,7 @@ pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_sketch;
+173
View File
@@ -0,0 +1,173 @@
//! Effect › Artistic › Smudge Stick, Sponge and Watercolor. Original filters built from public
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html> (Smudge Stick smudges the
//! dark areas with short diagonal strokes and brightens the light ones, Sponge paints highly
//! textured areas of contrasting colour, Watercolor simplifies detail with a medium brush loaded
//! with water and saturates the colour where tones change at edges). Visual approximations, not
//! reproductions of a proprietary algorithm; the ranges and defaults are chosen for VectorCraft.
//!
//! All three keep the object's coverage, so they paint nothing outside it. Their patterns lie in
//! document space around the object's centre, as the Brush Strokes' and Texture filters' do.
use super::blur;
use super::brushstrokes::{MARGIN, RIGHT, Smear, TONE_SOFTNESS, edges, mix, ramp, streak, stroke_reach, unpremultiply};
use super::pixelate::Place;
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, Px16, gaussian, to16};
const SALT_SMUDGE: u64 = 320;
const SALT_SPONGE: u64 = 328;
const SALT_WATER: u64 = 336;
/// The length (points) of Smudge Stick's short strokes for Stroke Length `stroke` (0..10); the
/// light areas are smudged with strokes twice as long.
fn smudge_length(stroke: f64) -> f64 {
1.0 + 1.5 * stroke.clamp(0.0, 10.0)
}
/// How far (document units) Smudge Stick reaches for Stroke Length `stroke`: its strokes and the
/// softened tones that choose them.
pub(super) fn smudge_reach(stroke: f64) -> f64 {
stroke_reach(2.0 * smudge_length(stroke)).max(3.0 * TONE_SOFTNESS + MARGIN)
}
/// The σ (points) Sponge softens the object by, for Brush Size `brush` (0..10) and Smoothness
/// `smoothness` (1..15).
fn sponge_softness(brush: f64, smoothness: f64) -> f64 {
(0.15 + 0.04 * smoothness.clamp(1.0, 15.0)) * sponge_pore(brush)
}
/// The size (points) of Sponge's pores for Brush Size `brush` (0..10).
fn sponge_pore(brush: f64) -> f64 {
2.0 + 1.5 * brush.clamp(0.0, 10.0)
}
/// How far (document units) Sponge reaches: the softened colours around a pixel.
pub(super) fn sponge_reach(brush: f64, smoothness: f64) -> f64 {
3.0 * sponge_softness(brush, smoothness) + MARGIN
}
/// The radius (points) of the brush that simplifies detail for Brush Detail `detail` (1..14): the
/// less detail, the wider the brush.
fn water_brush(detail: f64) -> f64 {
0.5 * (15.0 - detail.clamp(1.0, 14.0))
}
/// The σ (points) of the softening that finds Watercolor's edges.
const WATER_EDGE: f64 = 0.6;
/// How far (document units) Watercolor reaches for Brush Detail `detail`.
pub(super) fn watercolor_reach(detail: f64) -> f64 {
water_brush(detail) + 3.0 * WATER_EDGE + MARGIN
}
/// Smudge Stick: the dark areas smudged along short diagonal strokes `stroke` (0..10) long, the
/// light areas smudged with longer strokes and lightened towards white as strongly as `intensity`
/// (0..10) says, so they lose their detail; `highlight` (0..20) widens the area counted as light.
pub(super) fn smudge_stick(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke: f64, highlight: f64, intensity: f64) {
if ![stroke, highlight, intensity].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let len = smudge_length(stroke);
let (short, long) = (Smear::new(px, w, h, &place, len), Smear::new(px, w, h, &place, 2.0 * len));
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
// Tones above the threshold count as light; a wider highlight area lowers it.
let threshold = 0.9 - 0.5 * (highlight / 20.0).clamp(0.0, 1.0);
let lift = (0.7 * intensity / 10.0).clamp(0.0, 0.7) as f32;
let row = (0.25 * len).max(1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let own = straight(*p);
let paint = |smear: &Smear, salt: u64| {
let (c, a) = unpremultiply(smear.stroke(salt, q, RIGHT, row));
if a > 0.0 { c } else { own }
};
// Bristle marks along the dark strokes.
let bristle = (1.0 + 0.08 * streak(SALT_SMUDGE + 2, q, RIGHT, len, 0.5)) as f32;
let dark = paint(&short, SALT_SMUDGE).map(|v| v * bristle);
let light = paint(&long, SALT_SMUDGE + 1);
let t = ramp((tones.get(x, y) - threshold) / 0.2 + 0.5) as f32;
let bright = light.map(|v| v + (1.0 - v) * lift);
*p = premultiply(mix(dark, bright, t), f32::from(p[3]) / 255.0);
});
}
/// Sponge: the object as if dabbed with a sponge, soft blobs of lighter and darker colour about
/// `brush` (0..10) large; `definition` (0..25) sets how strongly they contrast and `smoothness`
/// (1..15) how soft their edges are.
pub(super) fn sponge(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush: f64, definition: f64, smoothness: f64) {
if ![brush, definition, smoothness].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let mut soft: Vec<Px16> = to16(px);
gaussian(&mut soft, w, sponge_softness(brush, smoothness) / place.px);
let pore = sponge_pore(brush);
let edge = 0.12 + 0.03 * smoothness.clamp(1.0, 15.0);
let amount = (definition / 25.0).clamp(0.0, 1.0);
let gain = 1.0 + 1.2 * amount;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let alpha = f32::from(p[3]) / 255.0;
let own = straight(*p);
let (c, a) = unpremultiply(soft.get(y * w + x).map_or([0.0; 4], |s| s.map(|v| f32::from(v) / 65535.0)));
let c = if a > 0.0 { c } else { own };
let q = place.at(x, y);
let n = 0.65 * smooth_noise(SALT_SPONGE, q, pore) + 0.35 * smooth_noise(SALT_SPONGE + 1, q, 0.45 * pore);
// -1 inside a dark pore, 1 on a light bridge between pores.
let blob = 2.0 * ramp(0.5 + n / (2.0 * edge)) - 1.0;
let l = f64::from(luma(c));
let target = (0.5 + (l + 0.55 * amount * blob - 0.5) * gain).clamp(0.0, 1.0);
// Darker towards black, lighter towards white: the hue stays.
let out = if target <= l {
let k = if l > 1e-3 { (target / l) as f32 } else { 0.0 };
c.map(|v| v * k)
} else {
let k = ((target - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32;
c.map(|v| v + (1.0 - v) * k)
};
*p = premultiply(out, alpha);
});
}
/// Watercolor: the object's detail simplified by a brush whose size `detail` (1..14) sets (the
/// larger, the more detail stays), the colour saturated and its pigment pooled where tones change
/// at edges, the shadows deepened as strongly as `shadow` (0..10) says, and `texture` (1..3)
/// grains of paper showing through.
pub(super) fn watercolor(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, shadow: f64, texture: u32) {
if ![detail, shadow].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let coverage: Vec<u8> = px.iter().map(|p| p[3]).collect();
// Simplify: average within areas of similar colour, keeping the bolder edges; a coarser brush
// (less detail) also merges colours further apart.
blur::smart(px, w, h, water_brush(detail) / place.px, 20.0 + 6.0 * (14.0 - detail.clamp(1.0, 14.0)), 7);
let found = edges(px, w, h, &place, WATER_EDGE);
let shadow = shadow.clamp(0.0, 10.0);
let paper = 0.02 * f64::from(texture.clamp(1, 3));
let grain = 1.0 + 0.5 * f64::from(texture.clamp(1, 3));
place.each(px, |x, y, p| {
let alpha = coverage.get(y * w + x).copied().unwrap_or(0);
if alpha == 0 {
*p = [0; 4];
return;
}
let c = straight(*p);
let e = (2.0 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0))).clamp(0.0, 1.0) as f32;
let l = luma(c);
// Saturated colour and darker pigment where the tones change.
let c = c.map(|v| (l + (v - l) * (1.0 + 0.9 * e)) * (1.0 - 0.12 * e));
let deep = ((1.0 - l / 0.5).clamp(0.0, 1.0) * 0.07 * shadow as f32).min(0.8);
let q = place.at(x, y);
let n = 0.6 * smooth_noise(SALT_WATER, q, grain) + 0.4 * smooth_noise(SALT_WATER + 1, q, 0.5 * grain);
let n = (paper * n) as f32;
*p = premultiply(c.map(|v| v * (1.0 - deep) + n), f32::from(alpha) / 255.0);
});
}
+418 -7
View File
@@ -1,12 +1,46 @@
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Halftone Pattern, Note Paper, Photocopy, Plaster,
//! Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
//! black and white; Water Paper flows colours on damp fibrous paper) and the public Photoshop Elements Artistic filters guide. Visual
//! approximations, not proprietary reproductions.
use super::pixelate::Place;
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, pick};
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
use vectorcraft_geom::{Point, Vec2};
use super::distort::displace;
use super::edges::sobel;
use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
const SALT_CHALK: u64 = 216;
const SALT_CHROME: u64 = 0xC40_4D45;
const SALT_TORN: u64 = 0x746f726e5f6564;
const SALT_WATER: u64 = 208;
const EDGE_SOFT: f64 = 0.8;
const TONE_SOFT: f64 = 1.0;
/// Light from the top left (document axes, y down), as on polished relief surfaces.
const CHROME_LIGHT: Vec2 = Vec2::new(-FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
/// How far (document units) Chalk & Charcoal samples softened tones and edges.
pub(super) const CHALK_CHARCOAL_REACH: f64 = 3.0 * TONE_SOFT + 1.0 + 3.0 * EDGE_SOFT;
fn ramp(t: f64) -> f64 {
t.clamp(0.0, 1.0)
}
fn tone_edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let s = sigma / place.px;
blur_plane(&mut tone, w, h, s);
blur_plane(&mut cover, w, h, s);
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
let norm = if norm.is_finite() { norm } else { 0.0 };
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
}
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
@@ -69,3 +103,380 @@ pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &P
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
/// Note Paper: the object embossed on fibrous paper. `balance` (0..50) shifts tones toward
/// highlights or shadows around the middle; `graininess` (0..10) adds paper fibre; `relief`
/// (0..50, points) is how strongly the tones stand out in the light. Coverage is kept.
pub(super) fn note_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, graininess: f64, relief: f64) {
if !(balance.is_finite() && graininess.is_finite() && relief.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let height: Vec<f32> = px
.iter()
.map(|p| {
if p[3] == 0 {
0.0
} else {
let mut l = f64::from(luma(straight(*p)));
l += (balance - 25.0) / 125.0;
l.clamp(0.0, 1.0) as f32
}
})
.collect();
let sample = |x: i32, y: i32| -> f32 {
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
return 0.0;
}
height.get(y as usize * w + x as usize).copied().unwrap_or(0.0)
};
let emboss = relief / 50.0;
let grain = graininess / 10.0;
let light = (-0.6_f64, -0.6, 0.8);
let ln = (light.0 * light.0 + light.1 * light.1 + light.2 * light.2).sqrt().max(1e-9);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let xi = x as i32;
let yi = y as i32;
let h0 = height.get(y * w + x).copied().unwrap_or(0.0);
let dx = (sample(xi + 1, yi) - sample(xi - 1, yi)) as f64;
let dy = (sample(xi, yi + 1) - sample(xi, yi - 1)) as f64;
let dz = emboss * 0.12;
let nx = -dx;
let ny = -dy;
let nz = dz;
let nn = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
let shade = ((nx * light.0 + ny * light.1 + nz * light.2) / (nn * ln) * 0.5 + 0.5).clamp(0.0, 1.0);
let tone = (0.35 + 0.65 * f64::from(h0)) * shade;
let q = place.at(x, y);
let fibre = if grain > 0.0 { value_noise(0x4e4f_5450, q.x * 0.35, q.y * 0.35) * grain * 0.18 } else { 0.0 };
let v = (tone + fibre).clamp(0.0, 1.0) as f32;
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
});
}
/// Chalk & Charcoal: light areas get chalky highlights and dark areas charcoal strokes and grain;
/// `chalk` and `charcoal` (0..20) set how strongly, `pressure` (0..5) the stroke weight. Black
/// chalk and charcoal on white paper, coverage kept.
pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, chalk: f64, charcoal: f64, pressure: f64) {
if !(chalk.is_finite() && charcoal.is_finite() && pressure.is_finite()) {
return;
}
let scale = (pressure.clamp(0.0, 5.0) / 5.0).clamp(0.0, 1.0);
let chalk_amt = (0.05 * chalk.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
let charcoal_amt = (0.05 * charcoal.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
if chalk_amt <= 0.0 && charcoal_amt <= 0.0 {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
let edges = tone_edges(px, w, h, &place, EDGE_SOFT);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let tone = tones.get(x, y);
let dark = (1.0 - tone).clamp(0.0, 1.0);
let light = tone.clamp(0.0, 1.0);
let edge = f64::from(edges.get(y * w + x).copied().unwrap_or(0.0));
let charcoal_stroke = value_noise(SALT_CHALK + 1, q.x * 1.1, q.y * 0.12);
let charcoal_grain = value_noise(SALT_CHALK, q.x * 0.35, q.y * 0.35);
let charcoal_mask = ramp((dark - 0.3) / 0.22)
* charcoal_amt
* (0.35 + 0.65 * edge)
* (0.45 + 0.55 * ramp(charcoal_stroke * 2.2 - 0.55))
* (0.6 + 0.4 * charcoal_grain);
let chalk_grain = value_noise(SALT_CHALK + 2, q.x * 0.45 + 7.0, q.y * 0.45);
let chalk_mask = ramp((light - 0.5) / 0.22) * chalk_amt * (0.55 + 0.45 * chalk_grain);
let down = (0.9 * charcoal_mask).clamp(0.0, 1.0) as f32;
let up = (0.8 * chalk_mask).clamp(0.0, 1.0) as f32;
let mut grey = luma(straight(*p));
grey *= 1.0 - down;
grey += (1.0 - grey) * up;
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
});
}
/// How far (document units) Chrome reaches for `smoothness` (0..10): 3 σ of the height-field blur
/// plus the Sobel neighbours.
pub(super) fn chrome_reach(smoothness: f64) -> f64 {
let smoothness = smoothness.clamp(0.0, 10.0);
3.0 * (0.4 + 0.55 * smoothness) + 1.0
}
/// Chrome: the object as a polished chrome surface lit from the top left. `detail` (0..10) adds
/// finer relief; `smoothness` (0..10) softens the height field before the highlights and shadows
/// are drawn. Coverage is kept.
pub(super) fn chrome(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64) {
if w == 0 || h == 0 {
return;
}
let detail = detail.clamp(0.0, 10.0);
let smoothness = smoothness.clamp(0.0, 10.0);
let Some(place) = Place::new(space, w, h) else { return };
let sigma = (0.4 + smoothness * 0.55) / place.px.max(1e-9);
let cell = (14.0 - detail * 1.2).max(1.5);
let bump = (detail / 10.0) * 0.4;
let strength = 2.5 + detail * 0.35;
let mut height: Vec<f32> = Vec::with_capacity(w * h);
let mut alpha: Vec<f32> = Vec::with_capacity(w * h);
for y in 0..h {
for x in 0..w {
let p = px[y * w + x];
let a = f32::from(p[3]) / 255.0;
alpha.push(a);
if a <= 0.0 {
height.push(0.0);
continue;
}
let q = place.at(x, y);
let base = f64::from(luma(straight(p)));
let n = if bump > 0.0 { bump * smooth_noise(SALT_CHROME, q, cell) } else { 0.0 };
height.push((base + n).clamp(0.0, 1.0) as f32);
}
}
blur_plane(&mut height, w, h, sigma);
blur_plane(&mut alpha, w, h, sigma);
let tone: Vec<f64> = height.iter().zip(&alpha).map(|(h, a)| if *a > 1e-4 { f64::from(*h / *a).clamp(0.0, 1.0) } else { 0.0 }).collect();
let px_unit = place.px.max(1e-9);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let at = |dx: isize, dy: isize| {
let (xx, yy) = ((x as isize + dx).clamp(0, w as isize - 1) as usize, (y as isize + dy).clamp(0, h as isize - 1) as usize);
tone[yy * w + xx]
};
let h0 = tone[y * w + x];
let gx = 0.5 * (at(1, 0) - at(-1, 0)) / px_unit;
let gy = 0.5 * (at(0, 1) - at(0, -1)) / px_unit;
let face = -(gx * CHROME_LIGHT.x + gy * CHROME_LIGHT.y);
let spec = (face * strength + (h0 - 0.5) * 1.5).tanh();
let grey = (0.5 + 0.5 * spec).clamp(0.0, 1.0);
*p = premultiply([grey as f32; 3], f32::from(p[3]) / 255.0);
});
}
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
}
/// How a surface whose height rises along `slope` faces a light from `toward`.
fn facing(slope: Vec2, toward: Vec2) -> f64 {
-slope.dot(toward)
}
/// The σ (document units) of Plaster's height blur at `smoothness` (1..15).
fn plaster_sigma_pt(smoothness: f64) -> f64 {
let smoothness = smoothness.clamp(1.0, 15.0);
0.15 + 0.85 * (smoothness - 1.0) / 14.0
}
/// How far (document units) Plaster's softened relief reaches at `smoothness` (1..15).
pub(super) fn plaster_reach(smoothness: f64) -> f64 {
if !smoothness.is_finite() {
return 3.0 * plaster_sigma_pt(8.0) + 0.5;
}
3.0 * plaster_sigma_pt(smoothness) + 0.5
}
/// Plaster: the object molded in relief plaster and lit from `light`. Dark areas are raised and
/// light areas recessed; `imageBalance` (1..50) shifts more of the image lower at higher values;
/// `smoothness` (1..15) softens the relief. Coverage is kept.
pub(super) fn plaster(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, light: Light) {
if !(image_balance.is_finite() && smoothness.is_finite() && space.px.is_finite() && space.px > 0.0)
|| w == 0
|| h == 0
|| w.checked_mul(h) != Some(px.len())
{
return;
}
let image_balance = image_balance.clamp(1.0, 50.0);
let smoothness = smoothness.clamp(1.0, 15.0);
let bias = (image_balance - 1.0) / 49.0;
let mut height: Vec<f32> = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let dark = (1.0 - luma(straight(*p))) * a;
(dark - bias as f32 * 0.65).clamp(0.0, 1.0)
})
.collect();
blur_plane(&mut height, w, h, plaster_sigma_pt(smoothness) / space.px);
let Some(place) = Place::new(space, w, h) else { return };
let step = (0.35f64).max(0.5 * place.px);
let strength = 0.55;
let toward = light.toward();
let at = |x: usize, y: usize, dx: i32, dy: i32| {
let (xx, yy) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
height[yy * w + xx]
};
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let h = height[y * w + x];
let (left, right, up, down) = (at(x, y, -1, 0), at(x, y, 1, 0), at(x, y, 0, -1), at(x, y, 0, 1));
let slope = Vec2::new(f64::from(right - left), f64::from(down - up)) / (2.0 * step);
let ridge = f64::from(h - 0.25 * (left + right + up + down)) / step;
let shade = strength * (facing(slope, toward) + ridge);
*p = premultiply(lit(straight(*p), shade), f32::from(p[3]) / 255.0);
});
}
/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain
/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each)
/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is
/// kept.
pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) {
if !(density.is_finite() && foreground.is_finite() && background.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
// Finer grain at higher density: cells from about 11 pt down to 1 pt.
let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2;
let fg = foreground.clamp(0.0, 50.0) / 50.0;
let bg = background.clamp(0.0, 50.0) / 50.0;
let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0);
const SALT: u64 = 0x5245_7469;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0);
let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size);
let fine = match (cell(q.x), cell(q.y)) {
(Some(i), Some(j)) => rand(SALT + 1, i, j, 2),
_ => 0.5,
};
let grain = 0.55 * coarse + 0.45 * fine;
let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 };
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance`
/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and
/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept.
pub(super) fn torn_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, contrast: f64) {
if !(image_balance.is_finite() && smoothness.is_finite() && contrast.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
if w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) {
return;
}
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
let cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let sigma = (0.12 * smoothness.clamp(0.0, 15.0)) / place.px.max(1e-9);
if sigma > 0.0 {
blur_plane(&mut tone, w, h, sigma);
}
let mut edges: Vec<f32> = sobel(&tone, w, h).into_iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c)).collect();
let edge_soft = (0.08 * smoothness.clamp(0.0, 15.0)) / place.px.max(1e-9);
if edge_soft > 0.0 {
blur_plane(&mut edges, w, h, edge_soft);
}
let peak = edges.iter().copied().fold(0.0f32, f32::max);
let balance = image_balance.clamp(0.0, 50.0);
// Lower balance favours ink (darker result); higher balance favours paper.
let balance_tone = 0.25 + balance / 50.0 * 0.75;
let steep = 4.0 + contrast.clamp(0.0, 20.0) * 0.45;
let hard = contrast.clamp(0.0, 20.0) >= 18.0;
let fray = 0.25 + 0.05 * smoothness.clamp(0.0, 15.0);
place.each(px, |x, y, p| {
let a = f32::from(p[3]) / 255.0;
if a <= 0.0 {
*p = [0; 4];
return;
}
let i = y * w + x;
let t = tone.get(i).copied().unwrap_or(0.0);
let e = if peak > 1e-6 { edges.get(i).copied().unwrap_or(0.0) / peak } else { 0.0 };
let noise = smooth_noise(SALT_TORN, place.at(x, y), 2.5 + 0.3 * smoothness) as f32;
let local = (1.0 - balance_tone) as f32 + e * fray as f32 * noise;
let mut ink = 1.0 / (1.0 + (steep as f32 * (t - local)).exp());
if hard {
ink = if ink > 0.5 { 1.0 } else { 0.0 };
}
let paper = 1.0 - ink.clamp(0.0, 1.0);
*p = premultiply([paper, paper, paper], a);
});
}
/// How far Water Paper's blotchy displacement can shift content (document units).
pub(super) fn water_paper_shift(fiber_length: f64) -> f64 {
0.12 * fiber_length.clamp(3.0, 50.0)
}
/// Water Paper: colours flow on damp fibrous paper. `fiber_length` (3..50) sets the scale of the
/// fibers and daubs; `brightness` and `contrast` (0..100) adjust the washed result. Ranges and
/// defaults are documented in [`crate::effect_catalog`] (unverified; helpx lists the controls only).
pub(super) fn water_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, fiber_length: f64, brightness: f64, contrast: f64) {
if !(fiber_length.is_finite() && brightness.is_finite() && contrast.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let fiber = fiber_length.clamp(3.0, 50.0);
let bright = (brightness / 100.0).clamp(0.0, 1.0);
let cont = (contrast / 100.0).clamp(0.0, 1.0);
let shift = water_paper_shift(fiber);
let wave = fiber * 0.4;
displace(px, w, h, &place, shift, |q| {
let a = 0.65 * smooth_noise(SALT_WATER, q, wave) + 0.35 * smooth_noise(SALT_WATER + 3, q, wave * 1.7);
let b = 0.65 * smooth_noise(SALT_WATER + 1, q, wave * 1.1) + 0.35 * smooth_noise(SALT_WATER + 4, q, wave * 0.85);
Vec2::new(a, b) * shift
});
let flow = (0.06 * fiber).max(0.35) / place.px.max(1e-9);
let mut planes: [Vec<f32>; 3] = std::array::from_fn(|ch| px.iter().map(|p| straight(*p)[ch] * f32::from(p[3]) / 255.0).collect());
for plane in &mut planes {
blur_plane(plane, w, h, flow);
}
for (i, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let a = f32::from(p[3]) / 255.0;
if a <= 0.0 {
continue;
}
let rgb = {
let mut rgb = [0.0f32; 3];
let mut ok = true;
for ch in 0..3 {
let Some(v) = planes[ch].get(i) else {
ok = false;
break;
};
rgb[ch] = (*v / a).clamp(0.0, 1.0);
}
if !ok {
continue;
}
rgb
};
*p = premultiply(rgb, a);
}
let lift = (bright - 0.5) * 0.35;
let gain = 0.35 + 0.65 * cont;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let along = smooth_noise(SALT_WATER + 5, q, fiber * 0.25);
let across = smooth_noise(SALT_WATER + 6, Point::new(q.y, q.x), fiber * 0.22);
let fiber_tex = (0.5 * (along + across) + 0.5).clamp(0.0, 1.0);
let paper = (0.82 + 0.18 * fiber_tex) as f32;
let mut rgb = straight(*p).map(|v| ((v + lift as f32 - 0.5) * gain as f32 + 0.5).clamp(0.0, 1.0));
rgb = rgb.map(|v| (v * paper).clamp(0.0, 1.0));
*p = premultiply(rgb, f32::from(p[3]) / 255.0);
});
}
+209
View File
@@ -0,0 +1,209 @@
//! Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp. Original filters built from public
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html> (the Sketch effects
//! redraw the object in the foreground and background colours, black on white here; Bas Relief
//! carves low relief lit from a direction, Charcoal draws bold edges and sketches the midtones in
//! diagonal strokes, Photocopy copies only the edges of large dark areas and takes the midtones to
//! black or white, Stamp simplifies the image to solid black and white as a rubber stamp would).
//! Visual approximations, not reproductions of a proprietary algorithm; the ranges and defaults
//! are chosen for VectorCraft.
//!
//! All four keep the object's coverage, so they paint nothing outside it. Their sizes are in
//! document points, whatever the raster's resolution.
use std::f64::consts::FRAC_1_SQRT_2;
use super::pixelate::{Place, cell, value_noise};
use super::texture::{Light, Tones, premultiply};
use super::{PixelSpace, blur_plane};
const SALT_STROKES: u64 = 440;
/// A pixel's size (points) at 72 ppi: how far the neighbours of a gradient add to a filter's reach.
const MARGIN: f64 = 1.0;
/// Redraw `p` as ink `ink` (0..1) of black on white paper, keeping its coverage.
fn paint(p: &mut [u8; 4], ink: f64) {
let v = if ink.is_finite() { (1.0 - ink).clamp(0.0, 1.0) as f32 } else { 1.0 };
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
}
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1 (0 when `t` is not a number).
fn ramp(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
t * t * (3.0 - 2.0 * t)
}
/// The tone at (`x`, `y`) and its gradient per pixel, from central differences.
fn gradient(tones: &Tones, x: usize, y: usize) -> (f64, f64, f64) {
let gx = 0.5 * (tones.get(x + 1, y) - tones.get(x.saturating_sub(1), y));
let gy = 0.5 * (tones.get(x, y + 1) - tones.get(x, y.saturating_sub(1)));
(tones.get(x, y), gx, gy)
}
/// The σ (points) Bas Relief softens the object by, for Detail `detail` (1..15) and Smoothness
/// `smoothness` (1..15).
fn relief_softness(detail: f64, smoothness: f64) -> f64 {
0.2 + 0.15 * smoothness.clamp(1.0, 15.0) + 0.1 * (15.0 - detail.clamp(1.0, 15.0))
}
/// How far (document units) Bas Relief reaches: the softened tones and their neighbours.
pub(super) fn relief_reach(detail: f64, smoothness: f64) -> f64 {
3.0 * relief_softness(detail, smoothness) + MARGIN
}
/// Bas Relief: the object carved in low relief and lit from `light`. Surfaces facing the light turn
/// white, those turned away black, flat areas grey with a hint of the object's own tones; `detail`
/// (1..15) keeps finer features and `smoothness` (1..15) rounds the carving.
pub(super) fn bas_relief(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64, light: Light) {
if !(detail.is_finite() && smoothness.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let sigma = relief_softness(detail, smoothness);
let tones = Tones::new(px, w, h, sigma / place.px);
let toward = light.toward();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let (tone, slope) = tones.slope(x, y, &place);
// A step from black to white, softened by σ, rises 0.4 per σ at its steepest.
let lit = -slope.dot(toward) * sigma;
paint(p, 1.0 - (0.5 + 0.2 * (tone - 0.5) + 2.5 * lit));
});
}
/// The σ (points) Charcoal softens the object by to find its edges, for Detail `detail` (0..5).
fn charcoal_softness(detail: f64) -> f64 {
0.25 + 0.25 * (5.0 - detail.clamp(0.0, 5.0)) + 0.25
}
/// The σ (points) that thickens Charcoal's edges, for Thickness `thickness` (1..7).
fn charcoal_thickening(thickness: f64) -> f64 {
0.35 * thickness.clamp(1.0, 7.0)
}
/// The distance (points) between the strokes sketching Charcoal's midtones.
fn charcoal_spacing(thickness: f64) -> f64 {
1.5 + 0.5 * thickness.clamp(1.0, 7.0)
}
/// How far (document units) Charcoal reaches: the edges, their thickening and the strokes' tones.
pub(super) fn charcoal_reach(thickness: f64, detail: f64) -> f64 {
(3.0 * (charcoal_softness(detail) + charcoal_thickening(thickness)) + 2.0 * MARGIN).max(2.0 * charcoal_spacing(thickness) + MARGIN)
}
/// Charcoal: bold, `thickness` (1..7) wide edges, as fine as `detail` (0..5) says, and the
/// midtones sketched in diagonal strokes; `balance` (0..100) sets how far into the lights the
/// charcoal reaches (50 is even).
pub(super) fn charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, detail: f64, balance: f64) {
if !(thickness.is_finite() && detail.is_finite() && balance.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let sigma = charcoal_softness(detail);
let spacing = charcoal_spacing(thickness);
let bias = (balance.clamp(0.0, 100.0) - 50.0) / 100.0 * 0.8;
// The edges: how much the softened tones change across σ, 1 for a step from black to white.
let edge_tones = Tones::new(px, w, h, sigma / place.px);
let mut edge: Vec<f32> = (0..w * h)
.map(|k| {
let (_, gx, gy) = gradient(&edge_tones, k % w, k / w);
let contrast = gx.hypot(gy) / place.px * sigma / 0.4;
let weakest = 0.35 - 0.2 * bias / 0.4;
ramp((contrast - weakest) / 0.3) as f32
})
.collect();
blur_plane(&mut edge, w, h, charcoal_thickening(thickness) / place.px);
// The strokes' tones are softer than the edges', so the strokes don't follow every speck.
let tones = Tones::new(px, w, h, 0.5 * spacing / place.px);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let bold = edge.get(y * w + x).map_or(0.0, |e| ((f64::from(*e) - 0.3) / 0.3).clamp(0.0, 1.0));
let dark = 1.0 - tones.get(x, y);
let share = ((dark + bias - 0.2) / 0.6).clamp(0.0, 1.0);
let q = place.at(x, y);
// Across the strokes, which run rising to the right, and along them.
let (across, along) = ((q.x + q.y) * FRAC_1_SQRT_2 / spacing, (q.x - q.y) * FRAC_1_SQRT_2 / spacing);
let stroke = match (share > 0.0, cell(across)) {
(true, Some(line)) => {
let f = across - line as f64;
// Each stroke breaks and swells along its length.
let swell = (0.6 + 0.8 * value_noise(SALT_STROKES, along / 6.0, line as f64)).clamp(0.0, 1.2);
let reach = (share * swell).min(1.0);
let soft = 2.0 * place.px / spacing;
(((reach - (2.0 * f - 1.0).abs()) / soft + 0.5).clamp(0.0, 1.0)) * f64::from(reach > 0.0)
}
_ => 0.0,
};
paint(p, bold.max(stroke));
});
}
/// The σ (points) of Photocopy's narrow softening for Detail `detail` (1..24): the less detail,
/// the wider.
fn copy_softness(detail: f64) -> f64 {
0.15 * (25.0 - detail.clamp(1.0, 24.0))
}
/// How much wider Photocopy's second softening is than its first.
const COPY_WIDER: f64 = 3.0;
/// How far (document units) Photocopy reaches: the wide softening.
pub(super) fn copy_reach(detail: f64) -> f64 {
3.0 * COPY_WIDER * copy_softness(detail) + MARGIN
}
/// Photocopy: black where a pixel is darker than its surroundings, white elsewhere. Large dark
/// areas copy only around their edges and midtones go to black or white. `detail` (1..24) keeps
/// finer features and `darkness` (1..50) copies more of the dark.
pub(super) fn photocopy(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, darkness: f64) {
if !(detail.is_finite() && darkness.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let sigma = copy_softness(detail) / place.px;
let narrow = Tones::new(px, w, h, sigma);
let wide = Tones::new(px, w, h, COPY_WIDER * sigma);
// How much darker than its surroundings a pixel must be to print.
let threshold = 0.02 + 0.10 * (1.0 - (darkness.clamp(1.0, 50.0) - 1.0) / 49.0);
let soft = 0.02;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
paint(p, ((wide.get(x, y) - narrow.get(x, y) - threshold) / soft + 0.5).clamp(0.0, 1.0));
});
}
/// The σ (points) Stamp softens the object by for Smoothness `smoothness` (1..50).
fn stamp_softness(smoothness: f64) -> f64 {
0.1 * smoothness.clamp(1.0, 50.0)
}
/// How far (document units) Stamp reaches: the softened tones and their neighbours.
pub(super) fn stamp_reach(smoothness: f64) -> f64 {
3.0 * stamp_softness(smoothness) + MARGIN
}
/// Stamp: the object as solid black and white, like a rubber stamp. `balance` (0..50) sets how much
/// of the object prints black and `smoothness` (1..50) rounds the shapes.
pub(super) fn stamp(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, smoothness: f64) {
if !(balance.is_finite() && smoothness.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let tones = Tones::new(px, w, h, stamp_softness(smoothness) / place.px);
let threshold = 0.2 + 0.6 * balance.clamp(0.0, 50.0) / 50.0;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let (tone, gx, gy) = gradient(&tones, x, y);
// A one pixel wide edge, whatever the softening.
paint(p, ((threshold - tone) / gx.hypot(gy).max(0.01) + 0.5).clamp(0.0, 1.0));
});
}
+647 -1
View File
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
}
}
#[test]
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
// that growth as the raster scales would show as the glow's width jumping with the zoom.
let mut dot = vec![0.0f32; 7 * 7];
dot[3 * 7 + 3] = 255.0;
let mut grown = dot.clone();
dilate_plane(&mut grown, 7, 7, 1.0);
assert_eq!(grown[2 * 7 + 2], 255.0);
assert_eq!(grown[7 + 3], 0.0);
let mut half = dot.clone();
dilate_plane(&mut half, 7, 7, 1.5);
assert_eq!(half[3 * 7 + 4], 255.0);
assert_eq!(half[3 * 7 + 5], 127.5);
assert_eq!(half[3 * 7 + 6], 0.0);
let mut same = dot.clone();
dilate_plane(&mut same, 7, 7, 0.0);
assert_eq!(same, dot);
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
@@ -1202,6 +1222,8 @@ fn texture_params_take_defaults_and_stay_in_range() {
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
("texture.texturizer", "texture", TEXTURES.as_slice()),
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
("artistic.roughPastels", "texture", TEXTURES.as_slice()),
("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
] {
let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
@@ -1406,6 +1428,29 @@ fn texturizer_lights_each_surface_from_its_side() {
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
}
#[test]
fn texturizer_draws_a_loaded_texture_as_relief() {
let w = 64;
let mid = grey(150);
let rings = stored(12, 10, |x, y| {
let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs();
let g = (v * 255.0).round() as u8;
[g, g, g, 255]
});
let run = |p: serde_json::Value| {
let mut d = flat(w, mid);
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
d
};
let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20}));
assert_ne!(relief, flat(w, mid));
for (_, builtin) in TEXTURES {
assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}");
}
assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid));
assert_eq!(fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("texture.texturizer", json!({})));
}
/// The Texture filters' patterns lie in document space around the object's centre: the same at
/// twice the resolution, and moving with the object.
#[test]
@@ -1978,6 +2023,47 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
}
}
/// Chalk & Charcoal of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn chalk_charcoal(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.chalkCharcoal", p).apply(&mut out, w, h, s);
out
}
#[test]
fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.chalkCharcoal", json!({})), PixelFx::ChalkCharcoal { chalk: 6.0, charcoal: 6.0, pressure: 2.0 });
assert_eq!(
fx("sketch.chalkCharcoal", json!({"chalkArea": 1e9, "charcoalArea": -2, "strokePressure": 99})),
PixelFx::ChalkCharcoal { chalk: 20.0, charcoal: 0.0, pressure: 5.0 }
);
let doc = crate::effect_info("sketch.chalkCharcoal").unwrap().params;
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert!(f.reach().unwrap() > 0.0);
}
#[test]
fn chalk_charcoal_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
let out = chalk_charcoal(json!({"chalkArea": 12, "charcoalArea": 12, "strokePressure": 4}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
if p[3] == 0 {
continue;
}
assert_eq!(p[0], p[1]);
assert_eq!(p[1], p[2]);
}
assert_ne!(out, src);
}
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
@@ -1985,11 +2071,57 @@ fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace
out
}
/// Torn Edges of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn torn(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.tornEdges", p).apply(&mut out, w, h, s);
out
}
/// Water Paper of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn water_paper(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.waterPaper", p).apply(&mut out, w, h, s);
out
}
/// The smallest period of `values` (their length when they don't repeat).
fn period(values: &[u8]) -> usize {
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
}
#[test]
fn graphic_pen_params_take_defaults_and_stay_in_range() {
use super::STROKE_DIRECTIONS;
assert_eq!(fx("sketch.graphicPen", json!({})), PixelFx::GraphicPen { length: 5.0, balance: 50.0, direction: StrokeDirection::RightDiagonal });
assert_eq!(
fx("sketch.graphicPen", json!({"strokeLength": 0, "lightDarkBalance": 200, "strokeDirection": "VERTICAL"})),
PixelFx::GraphicPen { length: 1.0, balance: 100.0, direction: StrokeDirection::Vertical }
);
let doc = crate::effect_info("sketch.graphicPen").unwrap().params;
assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
let f = fx("sketch.graphicPen", json!({"strokeLength": 15}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0));
assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
assert_eq!(
fx("sketch.graphicPen", json!({"strokeLength": 99})),
PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal }
);
}
/// Graphic Pen on a mid-grey field draws both black ink and white paper.
#[test]
fn graphic_pen_draws_ink_and_paper_on_grey() {
let (w, h) = (48, 48);
let src = image(w, h, |_, _| grey(32));
let mut out = src.clone();
fx("sketch.graphicPen", json!({"strokeLength": 4, "lightDarkBalance": 50})).apply(&mut out, w, h, &space(w, h));
let lumas: Vec<u8> = out.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
let (min, max) = lumas.iter().fold((255u8, 0u8), |(lo, hi), l| (lo.min(*l), hi.max(*l)));
assert!(min < 80 && max > 200, "min {min} max {max}");
assert_ne!(out, src, "the filter should change the raster");
}
#[test]
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
@@ -2013,6 +2145,73 @@ fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert_eq!(f.reach(), Some(0.0));
}
#[test]
fn note_paper_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.notePaper", json!({})), PixelFx::NotePaper { balance: 25.0, graininess: 0.0, relief: 4.0 });
assert_eq!(
fx("sketch.notePaper", json!({"imageBalance": -1, "graininess": 20, "relief": 100})),
PixelFx::NotePaper { balance: 0.0, graininess: 10.0, relief: 50.0 }
);
let doc = crate::effect_info("sketch.notePaper").unwrap().params;
assert!(doc.contains("imageBalance") && doc.contains("graininess") && doc.contains("relief"), "{doc}");
let f = fx("sketch.notePaper", json!({"relief": 12}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
/// Note Paper embosses a horizontal gradient and graininess adds fibre.
#[test]
fn note_paper_embosses_a_gradient_and_grain_changes_pixels() {
let (w, h) = (32, 32);
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
let flat = {
let mut out = src.clone();
fx("sketch.notePaper", json!({"relief": 0, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
out
};
let embossed = {
let mut out = src.clone();
fx("sketch.notePaper", json!({"relief": 50, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
out
};
assert_ne!(flat, embossed, "relief changes the emboss");
let grainy = {
let mut out = src.clone();
fx("sketch.notePaper", json!({"relief": 0, "graininess": 10})).apply(&mut out, w, h, &space(w, h));
out
};
assert_ne!(flat, grainy, "graininess adds fibre");
}
#[test]
fn water_paper_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.waterPaper", json!({})), PixelFx::WaterPaper { fiber_length: 15.0, brightness: 50.0, contrast: 50.0 });
assert_eq!(
fx("sketch.waterPaper", json!({"fiberLength": 1, "brightness": -5, "contrast": 200})),
PixelFx::WaterPaper { fiber_length: 3.0, brightness: 0.0, contrast: 100.0 }
);
let f = fx("sketch.waterPaper", json!({"fiberLength": 40}));
assert!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)) > 0.0);
assert!(f.reach().unwrap_or(0.0) > 0.0);
let doc = crate::effect_info("sketch.waterPaper").unwrap().params;
assert!(doc.contains("unverified"), "{doc}");
}
#[test]
fn water_paper_softens_and_textures_a_flat_fill_without_losing_coverage() {
let (w, h) = (64, 64);
let src = image(w, h, |_, _| [200, 80, 40, 255]);
let out = water_paper(json!({"fiberLength": 20, "brightness": 50, "contrast": 40}), &src, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().filter(|p| p[3] > 200).count() > w * h / 2, "most of the fill stays visible");
assert_ne!(out, src, "the wash changes pixels");
let spread = |d: &[u8]| {
let reds: Vec<u8> = d.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
let (min, max) = (reds.iter().min().unwrap(), reds.iter().max().unwrap());
max - min
};
assert!(spread(&out) > spread(&src) / 2, "fibers add tonal variation");
}
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
#[test]
fn halftone_line_period_is_the_size_at_any_resolution() {
@@ -2057,6 +2256,41 @@ fn halftone_pattern_type_changes_the_pattern() {
}
}
/// Plaster of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn plaster(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.plaster", p).apply(&mut out, w, h, s);
out
}
#[test]
fn plaster_params_take_defaults_and_stay_in_range() {
use super::texture::Light;
assert_eq!(fx("sketch.plaster", json!({})), PixelFx::Plaster { image_balance: 20.0, smoothness: 8.0, light: Light::Top });
assert_eq!(
fx("sketch.plaster", json!({"imageBalance": 0, "smoothness": 1e9, "lightDirection": "bottomRight"})),
PixelFx::Plaster { image_balance: 1.0, smoothness: 15.0, light: Light::BottomRight }
);
assert_eq!(fx("sketch.plaster", json!({"lightDirection": "nonsense"})), fx("sketch.plaster", json!({})));
let doc = crate::effect_info("sketch.plaster").unwrap().params;
assert!(doc.contains("\"bottomRight\""), "{doc}");
let f = fx("sketch.plaster", json!({"smoothness": 24}));
assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0)));
}
/// Plaster changes the image and different light directions give different results.
#[test]
fn plaster_light_direction_shifts_shading() {
let (w, h) = (48, 32);
let src = image(w, h, |x, y| grey((16 + (x + y) % 200) as u8));
let s = space(w, h);
let base = plaster(json!({"imageBalance": 20, "smoothness": 6, "lightDirection": "top"}), &src, w, h, &s);
assert_ne!(base, src, "plaster changes opaque pixels");
let left = plaster(json!({"lightDirection": "left"}), &src, w, h, &s);
let right = plaster(json!({"lightDirection": "right"}), &src, w, h, &s);
assert_ne!(left, right, "light direction changes the relief shading");
}
#[test]
fn halftone_contrast_sharpens_the_screen() {
let (w, h) = (64, 32);
@@ -2094,6 +2328,146 @@ fn halftone_draws_in_black_and_white_only() {
}
}
fn chrome(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.chrome", p).apply(&mut out, w, h, s);
out
}
/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.reticulation", p).apply(&mut out, w, h, s);
out
}
#[test]
fn chrome_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.chrome", json!({})), PixelFx::Chrome { detail: 4.0, smoothness: 4.0 });
assert_eq!(fx("sketch.chrome", json!({"detail": -1, "smoothness": 99})), PixelFx::Chrome { detail: 0.0, smoothness: 10.0 });
assert_eq!(fx("sketch.chrome", json!({"detail": "x"})), fx("sketch.chrome", json!({})));
let doc = crate::effect_info("sketch.chrome").unwrap().params;
assert!(doc.contains("detail") && doc.contains("smoothness"), "{doc}");
let f = fx("sketch.chrome", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(sketch::chrome_reach(4.0)));
assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(), Some(sketch::chrome_reach(10.0)));
}
/// Chrome redraws in greyscale, keeps coverage, and more detail changes the pattern.
#[test]
fn chrome_draws_polished_greyscale_relief() {
let (w, h) = (64, 64);
let src = image(w, h, |x, y| {
let t = ((x + y) * 4) as u8;
[t, t / 2, 255 - t, 255]
});
let soft = chrome(json!({"detail": 0, "smoothness": 8}), &src, w, h, &space(w, h));
let sharp = chrome(json!({"detail": 10, "smoothness": 0}), &src, w, h, &space(w, h));
assert_ne!(soft, sharp);
for out in [&soft, &sharp] {
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
assert!(p[0] == p[1] && p[1] == p[2], "greyscale {p:?}");
}
assert!(share(out, |p| p[0] > 10 && p[0] < 245) > 0.05, "midtones");
}
}
#[test]
fn reticulation_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 });
assert_eq!(
fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})),
PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 }
);
let f = fx("sketch.reticulation", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
#[test]
fn reticulation_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
let out = reticulation(json!({}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{p:?} from {q:?}");
}
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
}
#[test]
fn reticulation_density_changes_the_grain() {
let (w, h) = (64, 64);
let src = image(w, h, |_, _| grey(128));
let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h));
let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h));
assert_ne!(low, high);
}
#[test]
fn torn_edges_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.tornEdges", json!({})), PixelFx::TornEdges { image_balance: 25.0, smoothness: 11.0, contrast: 17.0 });
assert_eq!(
fx("sketch.tornEdges", json!({"imageBalance": 0, "smoothness": 99, "contrast": -1})),
PixelFx::TornEdges { image_balance: 0.0, smoothness: 15.0, contrast: 0.0 }
);
assert_eq!(
fx("sketch.tornEdges", json!({"imageBalance": 1e9, "smoothness": -3, "contrast": 1e9})),
PixelFx::TornEdges { image_balance: 50.0, smoothness: 0.0, contrast: 20.0 }
);
for junk in [json!({"imageBalance": "x"}), json!({"smoothness": null})] {
assert_eq!(fx("sketch.tornEdges", junk), fx("sketch.tornEdges", json!({})));
}
assert_eq!(fx("sketch.tornEdges", json!({})).reach(), None);
}
#[test]
fn torn_edges_draws_in_black_and_white_and_keeps_coverage() {
let (w, h) = (64, 48);
let src = ramp(w, h);
let out = torn(json!({"imageBalance": 12, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "black or white: {p:?}");
}
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
}
#[test]
fn torn_edges_image_balance_shifts_ink_on_a_grey_ramp() {
let (w, h) = (80, 16);
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
let params = json!({"imageBalance": 25, "contrast": 20, "smoothness": 5});
let out = torn(params, &src, w, h, &space(w, h));
let mut dark_ink = 0usize;
let mut bright_ink = 0usize;
for y in 0..h {
for x in 0..w / 4 {
if at(&out, w, x, y)[0] == 0 {
dark_ink += 1;
}
}
for x in 3 * w / 4..w {
if at(&out, w, x, y)[0] == 0 {
bright_ink += 1;
}
}
}
let dark_n = (w / 4) * h;
let bright_n = (w - 3 * w / 4) * h;
assert!(dark_ink as f64 / dark_n as f64 > bright_ink as f64 / bright_n as f64 + 0.2, "dark end of ramp is inkier than bright end");
let dark = share(&torn(json!({"imageBalance": 0, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)), |p| p[0] == 0);
let light = share(&torn(json!({"imageBalance": 50, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)), |p| p[0] == 0);
assert!(dark > light + 0.15, "low balance leaves more ink: dark={dark} light={light}");
}
/// The distinct colours of the covered pixels.
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
@@ -2111,11 +2485,22 @@ fn ramp(w: usize, h: usize) -> Vec<u8> {
#[test]
fn artistic_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("artistic.coloredPencil", json!({})), PixelFx::ColoredPencil { width: 5.0, pressure: 5.0, paper: 25.0 });
assert_eq!(
fx("artistic.coloredPencil", json!({"pencilWidth": 0, "strokePressure": 200, "paperBrightness": -5})),
PixelFx::ColoredPencil { width: 1.0, pressure: 15.0, paper: 0.0 }
);
assert_eq!(fx("artistic.coloredPencil", json!({})).reach(), Some(artistic::colored_pencil_reach(5.0)));
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
assert_eq!(
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
);
assert_eq!(fx("artistic.dryBrush", json!({})), PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 });
assert_eq!(
fx("artistic.dryBrush", json!({"brushSize": 1e308, "brushDetail": 0, "texture": -2})),
PixelFx::DryBrush { size: 10.0, detail: 0.0, texture: 1.0 }
);
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
assert_eq!(
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
@@ -2128,9 +2513,96 @@ fn artistic_params_take_defaults_and_stay_in_range() {
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
);
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
let under = PixelFx::Underpainting {
brush_size: 6.0,
texture_coverage: 16.0,
texture: Texture::Canvas,
scaling: 1.0,
relief: 4.0,
light: Light::Top,
invert: false,
};
assert_eq!(fx("artistic.underpainting", json!({})), under);
assert_eq!(
fx(
"artistic.underpainting",
json!({"brushSize": 99, "textureCoverage": -1, "texture": "brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})
),
PixelFx::Underpainting {
brush_size: 40.0,
texture_coverage: 0.0,
texture: Texture::Brick,
scaling: 0.5,
relief: 50.0,
light: Light::BottomRight,
invert: true,
}
);
assert_eq!(
fx("artistic.roughPastels", json!({})),
PixelFx::RoughPastels { length: 6.0, detail: 10.0, texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false }
);
assert_eq!(
fx(
"artistic.roughPastels",
json!({"strokeLength": 0, "strokeDetail": 99, "texture": -3, "scaling": 10, "relief": 60, "lightDirection": "up", "invert": true})
),
PixelFx::RoughPastels { length: 0.0, detail: 20.0, texture: Texture::Canvas, scaling: 0.5, relief: 50.0, light: Light::Top, invert: true }
);
assert_eq!(fx("artistic.fresco", json!({})), PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: 1.0 });
assert_eq!(
fx("artistic.fresco", json!({"brushSize": 0, "brushDetail": 99, "texture": 4.7})),
PixelFx::Fresco { brush_size: 1.0, brush_detail: 10.0, texture: 3.0 }
);
assert_eq!(fx("artistic.plasticWrap", json!({})), PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 });
assert_eq!(
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
);
for id in [
"artistic.coloredPencil",
"artistic.cutout",
"artistic.dryBrush",
"artistic.fresco",
"artistic.neonGlow",
"artistic.plasticWrap",
"artistic.posterEdges",
"artistic.underpainting",
] {
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
}
assert_eq!(fx("artistic.paintDaubs", json!({})), PixelFx::PaintDaubs { size: 10.0, sharpness: 25.0, kind: PaintDaubBrush::Simple });
assert_eq!(
fx("artistic.paintDaubs", json!({"brushSize": 0, "sharpness": 99, "brushType": "wideBlurry"})),
PixelFx::PaintDaubs { size: 1.0, sharpness: 40.0, kind: PaintDaubBrush::WideBlurry }
);
let doc = crate::effect_info("artistic.paintDaubs").unwrap().params;
assert!(PAINT_DAUB_BRUSH_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
assert!(fx("artistic.paintDaubs", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
for (id, key, table) in
[("artistic.underpainting", "texture", TEXTURES.as_slice()), ("artistic.underpainting", "lightDirection", LIGHT_DIRECTIONS.as_slice())]
{
let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
for (i, (_, v)) in table.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
}
}
assert!(fx("artistic.roughPastels", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
}
#[test]
fn colored_pencil_strokes_change_pixels_and_keep_coverage() {
let (w, h) = (48, 32);
let src = ramp(w, h);
let mut out = src.clone();
fx("artistic.coloredPencil", json!({"strokePressure": 12})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
let mut mild = src.clone();
fx("artistic.coloredPencil", json!({"strokePressure": 0, "paperBrightness": 0})).apply(&mut mild, w, h, &space(w, h));
assert_eq!(mild, src, "zero pressure and paper leaves the art unchanged");
}
#[test]
@@ -2145,8 +2617,21 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let colour = [0.3, 0.65, 1.0];
let filters = [
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 },
PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: 1.0 },
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
PixelFx::Underpainting {
brush_size: 6.0,
texture_coverage: 16.0,
texture: Texture::Canvas,
scaling: 1.0,
relief: 4.0,
light: Light::Top,
invert: false,
},
PixelFx::RoughPastels { length: 6.0, detail: 10.0, texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false },
];
for f in &filters {
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
@@ -2168,6 +2653,27 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
PixelFx::Underpainting {
brush_size: f64::NAN,
texture_coverage: 16.0,
texture: Texture::Canvas,
scaling: 1.0,
relief: 4.0,
light: Light::Top,
invert: false,
},
PixelFx::Underpainting {
brush_size: 6.0,
texture_coverage: f64::INFINITY,
texture: Texture::Canvas,
scaling: 1.0,
relief: 4.0,
light: Light::Top,
invert: false,
},
PixelFx::Fresco { brush_size: f64::NAN, brush_detail: 10.0, texture: 1.0 },
PixelFx::Fresco { brush_size: 2.0, brush_detail: f64::INFINITY, texture: 1.0 },
PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: f64::NEG_INFINITY },
] {
if run(&f, &space(w, h)) != src {
changed.push(format!("{f:?}"));
@@ -2176,6 +2682,63 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
}
#[test]
fn underpainting_softens_more_with_a_larger_brush_and_keeps_coverage() {
let (w, h) = (48, 32);
let src = ramp(w, h);
let diff = |brush: f64| {
let mut out = src.clone();
fx("artistic.underpainting", json!({"brushSize": brush, "textureCoverage": 0})).apply(&mut out, w, h, &space(w, h));
out.as_chunks::<4>()
.0
.iter()
.zip(src.as_chunks::<4>().0)
.filter(|(_, s)| s[3] == 255)
.map(|(o, s)| (0..3).map(|k| u32::from(o[k].abs_diff(s[k]))).sum::<u32>())
.sum::<u32>()
};
assert!(diff(40.0) > diff(2.0) * 2, "large brush {} vs small {}", diff(40.0), diff(2.0));
let mut out = src.clone();
fx("artistic.underpainting", json!({})).apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
}
#[test]
fn fresco_keeps_coverage_and_responds_to_texture() {
let (w, h) = (48, 32);
let src = ramp(w, h);
let mut low = src.clone();
let mut high = src.clone();
fx("artistic.fresco", json!({"texture": 1})).apply(&mut low, w, h, &space(w, h));
fx("artistic.fresco", json!({"texture": 3})).apply(&mut high, w, h, &space(w, h));
assert_ne!(low, src);
assert_ne!(high, src);
assert_ne!(low, high, "deeper texture changes the relief");
for (a, b) in low.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3], "alpha is preserved");
}
}
#[test]
fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() {
let (w, h) = (48, 32);
let src = image(w, h, |x, y| grey((((x + y) * 5) % 200 + 30) as u8));
let mut none = src.clone();
fx("artistic.plasticWrap", json!({"highlightStrength": 0})).apply(&mut none, w, h, &space(w, h));
assert_eq!(none, src);
let mut out = src.clone();
fx("artistic.plasticWrap", json!({})).apply(&mut out, w, h, &space(w, h));
assert!(out != src, "defaults brighten some pixels");
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
let mut weak = src.clone();
let mut strong = src.clone();
fx("artistic.plasticWrap", json!({"highlightStrength": 4})).apply(&mut weak, w, h, &space(w, h));
fx("artistic.plasticWrap", json!({"highlightStrength": 20})).apply(&mut strong, w, h, &space(w, h));
let lift =
|a: &[u8], b: &[u8]| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).map(|(o, s)| i32::from(o[0]) - i32::from(s[0])).sum::<i32>();
assert!(lift(&strong, &src) > lift(&weak, &src), "stronger highlights lift more");
}
#[test]
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
let (w, h) = (64, 24);
@@ -2190,6 +2753,20 @@ fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
}
}
#[test]
fn rough_pastels_keeps_coverage_and_longer_strokes_smear_more() {
let (w, h) = (48, 48);
let src = ramp(w, h);
let mut short = src.clone();
let mut long = src.clone();
fx("artistic.roughPastels", json!({"strokeLength": 8, "strokeDetail": 18, "relief": 12})).apply(&mut short, w, h, &space(w, h));
fx("artistic.roughPastels", json!({"strokeLength": 32, "strokeDetail": 18, "relief": 12})).apply(&mut long, w, h, &space(w, h));
assert!(short.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
let changed = |a: &Vec<u8>, b: &Vec<u8>| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(o, s)| o != s).count();
assert!(changed(&short, &src) > w * h / 8, "short strokes repaint many pixels");
assert!(changed(&long, &short) > w * h / 16, "longer strokes smear differently");
}
#[test]
fn cutout_simplicity_smooths_the_pieces_edges() {
// A noisy grey: simpler edges join the speckles into fewer changes between neighbours.
@@ -2203,6 +2780,28 @@ fn cutout_simplicity_smooths_the_pieces_edges() {
assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0));
}
#[test]
fn paint_daubs_coarsens_a_ramp_and_keeps_coverage() {
let (w, h) = (64, 32);
let src = ramp(w, h);
let mut out = src.clone();
fx("artistic.paintDaubs", json!({"brushSize": 28, "sharpness": 35})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src, "dabbed art differs from the ramp");
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
}
#[test]
fn paint_daubs_larger_brushes_flatten_more() {
let (w, h) = (80, 40);
let src = ramp(w, h);
let dab_colours = |size: f64| {
let mut out = src.clone();
fx("artistic.paintDaubs", json!({"brushSize": size, "sharpness": 20})).apply(&mut out, w, h, &space(w, h));
colours(&out).len()
};
assert!(dab_colours(35.0) < dab_colours(8.0), "{} vs {}", dab_colours(35.0), dab_colours(8.0));
}
#[test]
fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() {
let (w, h) = (40, 20);
@@ -2237,6 +2836,24 @@ fn poster_edges_thickness_widens_the_lines() {
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
}
#[test]
fn dry_brush_repaints_and_preserves_coverage() {
let (w, h) = (48, 24);
let src = ramp(w, h);
let mut out = src.clone();
fx("artistic.dryBrush", json!({})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
for (a, b) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) {
assert_eq!(a[3], b[3], "coverage preserved");
}
let mut clear = vec![0u8; w * h * 4];
let before = clear.clone();
fx("artistic.dryBrush", json!({})).apply(&mut clear, w, h, &space(w, h));
assert_eq!(clear, before);
let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach();
assert!(reach.is_some_and(|r| r > 0.0));
}
#[test]
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
let (w, h) = (40, 20);
@@ -2274,6 +2891,8 @@ fn artistic_filters_look_the_same_at_any_resolution() {
("artistic.cutout", json!({"edgeSimplicity": 6})),
("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})),
("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})),
("artistic.underpainting", json!({"brushSize": 12, "textureCoverage": 20, "relief": 10})),
("artistic.roughPastels", json!({"strokeLength": 12, "strokeDetail": 15, "texture": "burlap", "relief": 10})),
] {
let (mut a, mut b) = (coarse.clone(), fine.clone());
fx(id, p.clone()).apply(&mut a, w, h, &space(w, h));
@@ -2289,3 +2908,30 @@ fn artistic_filters_look_the_same_at_any_resolution() {
assert!(diff < 12.0, "{id}: mean difference {diff}");
}
}
#[test]
fn palette_knife_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("artistic.paletteKnife", json!({})), PixelFx::PaletteKnife { stroke_size: 10.0, stroke_detail: 2.0, softness: 3.0 });
assert_eq!(
fx("artistic.paletteKnife", json!({"strokeSize": 0, "strokeDetail": 0, "softness": 99})),
PixelFx::PaletteKnife { stroke_size: 1.0, stroke_detail: 1.0, softness: 10.0 }
);
assert!(fx("artistic.paletteKnife", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0);
}
#[test]
fn palette_knife_smears_a_ramp_and_keeps_coverage() {
let (w, h) = (48, 24);
let src = ramp(w, h);
let mut out = src.clone();
fx("artistic.paletteKnife", json!({"strokeSize": 20, "strokeDetail": 1, "softness": 0})).apply(&mut out, w, h, &space(w, h));
assert_ne!(out, src);
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
let mut gentle = src.clone();
fx("artistic.paletteKnife", json!({"strokeSize": 20, "strokeDetail": 3, "softness": 0})).apply(&mut gentle, w, h, &space(w, h));
let mean = |buf: &Vec<u8>| {
let n = buf.len() / 4;
buf.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).sum::<f64>() / n as f64
};
assert!((mean(&gentle) - mean(&src)).abs() < (mean(&out) - mean(&src)).abs());
}
+189
View File
@@ -0,0 +1,189 @@
//! Artistic › Smudge Stick, Sponge and Watercolor.
use serde_json::json;
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Affine, Point};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
}
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.clone(),
other => panic!("{id}: {other:?}"),
}
}
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut out = src.to_vec();
fx(id, p).apply(&mut out, w, h, &space(w, h));
out
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
/// The mean of channel 0 over the columns `x0..x1`.
fn mean(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
let rows = d.len() / 4 / w;
let sum: f64 = (0..rows).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f64::from(d[(y * w + x) * 4])).sum();
sum / (rows * (x1 - x0)) as f64
}
/// The summed difference between horizontal neighbours of channel 0 over columns `x0..x1`.
fn roughness(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
let rows = d.len() / 4 / w;
(0..rows)
.flat_map(|y| (x0..x1 - 1).map(move |x| (x, y)))
.map(|(x, y)| (f64::from(d[(y * w + x) * 4]) - f64::from(d[(y * w + x + 1) * 4])).abs())
.sum()
}
fn spread(d: &[u8]) -> f64 {
let v: Vec<f64> = d.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).collect();
let m = v.iter().sum::<f64>() / v.len() as f64;
(v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64).sqrt()
}
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
fn survives(id: &str, params: &[serde_json::Value]) {
let (w, h) = (9, 7);
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
for p in params {
for s in [tiny, huge, nan, space(w, h)] {
let mut out = src.clone();
fx(id, p.clone()).apply(&mut out, w, h, &s);
assert_eq!(out.len(), src.len());
}
}
// A size that doesn't match, and an empty raster, do nothing.
let mut odd = vec![1u8; 10];
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
assert_eq!(odd, vec![1u8; 10]);
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
}
#[test]
fn smudge_stick_smudges_the_dark_and_lightens_the_light() {
assert_eq!(fx("artistic.smudgeStick", json!({})), PixelFx::SmudgeStick { stroke: 2.0, highlight: 12.0, intensity: 10.0 });
assert_eq!(
fx("artistic.smudgeStick", json!({"strokeLength": 1e308, "highlightArea": -5, "intensity": "x"})),
PixelFx::SmudgeStick { stroke: 10.0, highlight: 0.0, intensity: 10.0 }
);
// A fine checkerboard of dark tones on the left, of light tones on the right, and a half
// transparent bottom.
let (w, h) = (64, 48);
let src = image(w, h, |x, y| {
let on = (x + y) % 2 == 0;
let v = match (x < 32, on) {
(true, true) => 20,
(true, false) => 70,
(false, true) => 190,
(false, false) => 240,
};
if y < 40 { grey(v) } else { [v / 2, v / 2, v / 2, 128] }
});
let out = run("artistic.smudgeStick", json!({}), &src, w, h);
assert_eq!(out, run("artistic.smudgeStick", json!({}), &src, w, h), "draws the same twice");
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
// The dark detail is smudged away, the light areas brighter.
assert!(roughness(&out, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28));
assert!(mean(&out, w, 36, 60) > mean(&src, w, 36, 60) + 10.0);
// Intensity 0 leaves the light areas unlightened.
let flat = run("artistic.smudgeStick", json!({"intensity": 0}), &src, w, h);
assert!(mean(&flat, w, 36, 60) < mean(&out, w, 36, 60) - 5.0);
survives(
"artistic.smudgeStick",
&[json!({}), json!({"strokeLength": 1e308, "highlightArea": 1e308, "intensity": 1e308}), json!({"strokeLength": "NaN"})],
);
}
#[test]
fn sponge_dabs_contrasting_blobs_and_keeps_the_hue() {
assert_eq!(fx("artistic.sponge", json!({})), PixelFx::Sponge { brush: 2.0, definition: 12.0, smoothness: 5.0 });
assert_eq!(
fx("artistic.sponge", json!({"brushSize": -1, "definition": 1e308, "smoothness": 0})),
PixelFx::Sponge { brush: 0.0, definition: 25.0, smoothness: 1.0 }
);
let (w, h) = (80, 60);
let flat = image(w, h, |_, y| if y < 50 { [200, 90, 60, 255] } else { [100, 45, 30, 128] });
let out = run("artistic.sponge", json!({"definition": 25}), &flat, w, h);
assert_eq!(out, run("artistic.sponge", json!({"definition": 25}), &flat, w, h), "draws the same twice");
assert!(out.as_chunks::<4>().0.iter().zip(flat.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
// A flat colour breaks up into lighter and darker blobs of the same hue.
let top = &out[..w * 50 * 4];
assert!(spread(top) > 15.0, "{}", spread(top));
assert!(top.as_chunks::<4>().0.iter().all(|p| p[0] >= p[1] && p[1] >= p[2]), "red stays the strongest channel");
// Definition 0 leaves a flat colour as it is; more definition, more contrast.
let none = run("artistic.sponge", json!({"definition": 0}), &flat, w, h);
assert!(spread(&none[..w * 50 * 4]) < 1.0);
let some = run("artistic.sponge", json!({"definition": 8}), &flat, w, h);
assert!(spread(&some[..w * 50 * 4]) < spread(top));
survives("artistic.sponge", &[json!({}), json!({"brushSize": 1e308, "definition": 1e308, "smoothness": 1e308}), json!({"brushSize": "NaN"})]);
}
#[test]
fn watercolor_simplifies_detail_and_saturates_edges() {
assert_eq!(fx("artistic.watercolor", json!({})), PixelFx::Watercolor { detail: 9.0, shadow: 1.0, texture: 1 });
assert_eq!(
fx("artistic.watercolor", json!({"brushDetail": 99, "shadowIntensity": -1, "texture": 2.6})),
PixelFx::Watercolor { detail: 14.0, shadow: 0.0, texture: 3 }
);
// Noisy grey on the left, a step from light red to dark blue on the right, a transparent row.
let (w, h) = (64, 48);
let src = image(w, h, |x, y| {
if y >= 44 {
return [0; 4];
}
if x < 32 {
grey(if (x * 7 + y * 13) % 5 < 2 { 90 } else { 150 })
} else if x < 48 {
[200, 120, 120, 255]
} else {
[60, 70, 110, 255]
}
});
let few = run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h);
let many = run("artistic.watercolor", json!({"brushDetail": 14}), &src, w, h);
assert_eq!(few, run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h), "draws the same twice");
assert!(few.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept, nothing painted outside");
assert!(roughness(&few, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28), "less detail, simpler");
assert!(roughness(&few, w, 4, 28) < roughness(&many, w, 4, 28));
// Colour is saturated where the tones change, and shadows deepen with Shadow Intensity.
let chroma = |d: &[u8], x: usize, y: usize| {
let i = (y * w + x) * 4;
f64::from(d[i].max(d[i + 1]).max(d[i + 2])) - f64::from(d[i].min(d[i + 1]).min(d[i + 2]))
};
assert!(chroma(&few, 47, 20) > chroma(&src, 47, 20) || chroma(&few, 48, 20) > chroma(&src, 48, 20));
assert!(mean(&run("artistic.watercolor", json!({"shadowIntensity": 10}), &src, w, h), w, 4, 28) < mean(&few, w, 4, 28));
// Texture adds paper grain to a flat colour.
let flat = image(w, h, |_, _| grey(128));
let (t1, t3) = (run("artistic.watercolor", json!({"texture": 1}), &flat, w, h), run("artistic.watercolor", json!({"texture": 3}), &flat, w, h));
assert!(spread(&t3) > spread(&t1) && spread(&t1) > 0.2);
survives(
"artistic.watercolor",
&[json!({}), json!({"brushDetail": -1e308, "shadowIntensity": 1e308, "texture": 1e308}), json!({"brushDetail": "NaN"})],
);
}
+181
View File
@@ -0,0 +1,181 @@
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp.
use serde_json::json;
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
}
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.clone(),
other => panic!("{id}: {other:?}"),
}
}
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut out = src.to_vec();
fx(id, p).apply(&mut out, w, h, &space(w, h));
out
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
/// Channel 0 of pixel (`x`, `y`).
fn at(d: &[u8], w: usize, x: usize, y: usize) -> u8 {
d[(y * w + x) * 4]
}
/// How many pixels of channel 0 are darker than 128.
fn black(d: &[u8]) -> usize {
d.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count()
}
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
fn survives(id: &str, params: &[serde_json::Value]) {
let (w, h) = (9, 7);
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
for p in params {
for s in [tiny, huge, nan, space(w, h)] {
let mut out = src.clone();
fx(id, p.clone()).apply(&mut out, w, h, &s);
assert_eq!(out.len(), src.len());
let alphas = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| p[3]).collect::<Vec<_>>();
if s.px == 1.0 {
assert_eq!(alphas(&out), alphas(&src), "{id}: coverage kept");
}
}
}
// A size that doesn't match, and an empty raster, do nothing.
let mut odd = vec![1u8; 10];
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
assert_eq!(odd, vec![1u8; 10]);
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
}
#[test]
fn photocopy_copies_the_edges_of_dark_areas() {
assert_eq!(fx("sketch.photocopy", json!({})), PixelFx::Photocopy { detail: 7.0, darkness: 25.0 });
assert_eq!(fx("sketch.photocopy", json!({"detail": 1e308, "darkness": -5})), PixelFx::Photocopy { detail: 24.0, darkness: 1.0 });
let (w, h) = (64, 16);
let src = image(w, h, |x, _| grey(if x < 32 { 30 } else { 230 }));
let out = run("sketch.photocopy", json!({"detail": 20}), &src, w, h);
// Black just on the dark side of the edge; white in the dark area's middle and in the light.
assert!(at(&out, w, 30, 8) < 64, "edge {}", at(&out, w, 30, 8));
assert!(at(&out, w, 6, 8) > 192, "dark area {}", at(&out, w, 6, 8));
assert!(at(&out, w, 50, 8) > 192, "light area {}", at(&out, w, 50, 8));
assert_eq!(out, run("sketch.photocopy", json!({"detail": 20}), &src, w, h), "deterministic");
// A higher Darkness copies more.
let more = run("sketch.photocopy", json!({"detail": 20, "darkness": 50}), &src, w, h);
let less = run("sketch.photocopy", json!({"detail": 20, "darkness": 1}), &src, w, h);
assert!(black(&more) >= black(&less));
survives("sketch.photocopy", &[json!({}), json!({"detail": 1e308, "darkness": "NaN"}), json!({"detail": -1e308, "darkness": [1]})]);
}
#[test]
fn stamp_prints_solid_black_and_white() {
assert_eq!(fx("sketch.stamp", json!({})), PixelFx::Stamp { balance: 25.0, smoothness: 5.0 });
assert_eq!(fx("sketch.stamp", json!({"lightDarkBalance": 1e308, "smoothness": -1})), PixelFx::Stamp { balance: 50.0, smoothness: 1.0 });
let (w, h) = (64, 8);
let src = image(w, h, |x, _| grey((x * 4) as u8));
let few = run("sketch.stamp", json!({"lightDarkBalance": 0}), &src, w, h);
let many = run("sketch.stamp", json!({"lightDarkBalance": 50}), &src, w, h);
assert!(black(&many) > black(&few), "{} vs {}", black(&many), black(&few));
// Pure black and white, but for the one pixel wide edges.
let mixed = many.as_chunks::<4>().0.iter().filter(|p| p[0] != 0 && p[0] != 255).count();
assert!(mixed * 10 < many.len() / 4, "{mixed} grey pixels");
assert_eq!(at(&many, w, 0, 4), 0);
assert_eq!(at(&many, w, 63, 4), 255);
survives(
"sketch.stamp",
&[json!({}), json!({"lightDarkBalance": "NaN", "smoothness": 1e308}), json!({"lightDarkBalance": -1e308, "smoothness": null})],
);
}
#[test]
fn charcoal_draws_bold_edges_and_sketches_the_midtones() {
assert_eq!(fx("sketch.charcoal", json!({})), PixelFx::Charcoal { thickness: 1.0, detail: 1.0, balance: 50.0 });
assert_eq!(
fx("sketch.charcoal", json!({"thickness": 1e308, "detail": -5, "lightDarkBalance": "x"})),
PixelFx::Charcoal { thickness: 7.0, detail: 0.0, balance: 50.0 }
);
let (w, h) = (64, 48);
// Black on the left, white on the right: a bold line at the edge, nothing in the white.
let step = image(w, h, |x, _| grey(if x < 32 { 0 } else { 255 }));
let out = run("sketch.charcoal", json!({}), &step, w, h);
assert!((28..36).any(|x| at(&out, w, x, 24) < 64), "edge");
assert!((48..64).all(|x| at(&out, w, x, 24) == 255), "white stays white");
// A midtone is sketched in strokes, more of it the more the balance reaches into the lights.
let mid = image(w, h, |_, _| grey(128));
let sketched = run("sketch.charcoal", json!({}), &mid, w, h);
let (n, total) = (black(&sketched), w * h);
assert!(n > total / 50 && n < total * 9 / 10, "{n} of {total}");
let darker = run("sketch.charcoal", json!({"lightDarkBalance": 100}), &mid, w, h);
let lighter = run("sketch.charcoal", json!({"lightDarkBalance": 0}), &mid, w, h);
assert!(black(&darker) > black(&lighter));
assert_eq!(sketched, run("sketch.charcoal", json!({}), &mid, w, h), "deterministic");
let white = image(w, h, |_, _| grey(255));
assert_eq!(black(&run("sketch.charcoal", json!({"lightDarkBalance": 0}), &white, w, h)), 0);
survives(
"sketch.charcoal",
&[json!({}), json!({"thickness": 1e308, "detail": "NaN", "lightDarkBalance": [1]}), json!({"thickness": -1e308, "detail": 1e308})],
);
}
#[test]
fn bas_relief_lights_the_surfaces_facing_the_light() {
assert_eq!(fx("sketch.basRelief", json!({})), PixelFx::BasRelief { detail: 13.0, smoothness: 3.0, light: Light::Bottom });
assert_eq!(
fx("sketch.basRelief", json!({"detail": 0, "smoothness": 1e308, "lightDirection": "Top"})),
PixelFx::BasRelief { detail: 1.0, smoothness: 15.0, light: Light::Top }
);
// Dark above, light below: the edge is lit from one side and shaded from the other.
let (w, h) = (16, 48);
let src = image(w, h, |_, y| grey(if y < 24 { 20 } else { 235 }));
let below = run("sketch.basRelief", json!({"lightDirection": "bottom"}), &src, w, h);
let above = run("sketch.basRelief", json!({"lightDirection": "top"}), &src, w, h);
let edge = (0..h).map(|y| (y, i32::from(at(&below, w, 8, y)) - i32::from(at(&above, w, 8, y)))).max_by_key(|(_, d)| d.abs());
assert!(edge.is_some_and(|(_, d)| d.abs() > 100), "{edge:?}");
// Flat areas are grey.
for y in [2, 45] {
let v = at(&below, w, 8, y);
assert!((90..190).contains(&v), "flat {v}");
}
survives(
"sketch.basRelief",
&[json!({}), json!({"detail": "NaN", "smoothness": 1e308, "lightDirection": 3}), json!({"detail": -1e308, "smoothness": null})],
);
}
#[test]
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] {
let f = fx(id, json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
}
}
+24 -6
View File
@@ -7,9 +7,11 @@
//! whose slopes, taken in document units, are lit from one side.
use std::f64::consts::{FRAC_1_SQRT_2, PI};
use std::sync::Arc;
use vectorcraft_geom::{Point, Vec2};
use super::loaded::{LOADED, LoadedTexture, texture};
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
use super::{PixelSpace, blur_plane, pick};
@@ -65,13 +67,19 @@ impl Grain {
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
/// A Texturizer surface ([`TEXTURES`]), made in code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// Texturizer's Texture menu once a texture is loaded: [`TEXTURES`], then the loaded one.
pub const TEXTURES_LOADED: [(&str, &str); 5] =
[("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone"), ("Loaded Texture", LOADED)];
/// A Texturizer surface ([`TEXTURES`]), made in code or loaded from an image.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Texture {
Brick,
Burlap,
Canvas,
Sandstone,
/// An image the user loaded ([`LoadedTexture`]).
Loaded(Arc<LoadedTexture>),
}
impl Texture {
@@ -82,13 +90,23 @@ impl Texture {
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
}
/// The texture of `value` with `stored` the effect's `textureImage`: the loaded texture for
/// [`LOADED`] (any case) when `stored` holds one, else as [`Texture::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) => Texture::Loaded(t),
None => Self::parse(value),
}
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
pub(super) fn height(self, p: Point) -> f64 {
pub(super) fn height(&self, p: Point) -> f64 {
match self {
Texture::Brick => brick(p),
Texture::Burlap => weave(p, 7.0, true),
Texture::Canvas => weave(p, 4.0, false),
Texture::Sandstone => sandstone(p),
Texture::Loaded(t) => t.height(p),
}
}
}
@@ -128,7 +146,7 @@ impl Light {
}
/// The unit vector towards the light (document axes, y down).
fn toward(self) -> Vec2 {
pub(super) fn toward(self) -> Vec2 {
let d = FRAC_1_SQRT_2;
match self {
Light::Bottom => Vec2::new(0.0, 1.0),
@@ -269,7 +287,7 @@ impl Tones {
}
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
pub(super) fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
// Per pixel → per document unit: through the transpose of the document → pixel map.
@@ -556,7 +574,7 @@ pub(super) fn texturizer(
w: usize,
h: usize,
space: &PixelSpace,
texture: Texture,
texture: &Texture,
scaling: f64,
relief: f64,
light: Light,
+304
View File
@@ -0,0 +1,304 @@
//! Use Preview Bounds: objects measured as they are drawn, with their geometry effects.
//!
//! [`drawn_art`] gives an object whose geometry effects change what it draws as plain art. A path
//! or compound path is given as its effected geometry, and a fill or stroke with geometry effects
//! of its own paints its own geometry. Type, images, live objects and symbol instances are given as
//! their reshaped art ([`reshape`]), and a symbol instance is also given as its reshaped art when
//! its symbol's art has geometry effects. Groups and layers with geometry or Pathfinder effects of
//! their own, or with a fill or stroke of their own that has geometry effects or paints members
//! with geometry effects, are given as their evaluated art ([`evaluate_container`]). Blends,
//! envelopes and repeats whose objects have geometry effects are given as their evaluated art
//! ([`expanded_live_group`]), with each of those objects given as its own art. A clip group or
//! layer whose clipping path has strokes with geometry effects is given as that path's geometry
//! before its effects, which the renderer clips by, and those strokes as drawn. Fills and
//! strokes that paint nothing (hidden, or painted None) are left out. [`bounds_of`],
//! [`oriented_bounds`] and [`anchor_bounds`] measure the visual bounds of that art, strokes
//! included, where [`Document::bounds_of`], [`Document::oriented_bounds`] and
//! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows,
//! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured
//! with its geometry effects, and the other instances in it are measured without them.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, OrientedBox, Selection, SymbolBoxes};
use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt};
use crate::group::has_own_paint;
use crate::reshape::{geometry_effects, leaf_geometry};
use crate::{
GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline,
outline_art, paints, reshape,
};
/// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects:
/// past it, the other instances in the object are measured without geometry effects.
const MAX_INSTANCES: usize = 4096;
/// The bounds of `ids`: with `preview`, their visual bounds as drawn ([`visual_bounds`]), else
/// their geometric bounds ([`Document::bounds_of`]).
pub fn bounds_of(doc: &Document, ids: &[NodeId], preview: bool) -> Option<Rect> {
if !preview {
return doc.bounds_of(ids, false);
}
let symbols = doc.symbol_boxes();
let drawn = Drawn::new(&symbols);
ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, drawn.next().visual_bounds(n)))
}
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]), measured as
/// [`bounds_of`] measures them: [`Document::oriented_bounds`] with `preview` false.
pub fn oriented_bounds(doc: &Document, ids: &[NodeId], preview: bool) -> Option<OrientedBox> {
if !preview {
return doc.oriented_bounds(ids, false);
}
let angle = doc.bbox_angle(ids);
if angle == 0.0 {
return bounds_of(doc, ids, true).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let symbols = doc.symbol_boxes();
let drawn = Drawn::new(&symbols);
let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, drawn.next().bounds_in(n, to_local)))?;
Some(OrientedBox { angle, rect })
}
/// With anchors selected with Direct Selection, the box around those anchors and the objects
/// selected whole, measured by [`bounds_of`]. `None` without any ([`Selection::anchor_bounds`]).
pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Option<Rect> {
if selection.anchors.is_empty() {
return None;
}
union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc))
}
/// The visual bounds of `n` as drawn: those of its [`drawn_art`], else
/// [`Node::visual_bounds_with`].
pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
Drawn::new(symbols).visual_bounds(n)
}
/// [`visual_bounds`] of `n` as `a` would place it, without changing it ([`Node::bounds_in`]):
/// the art is drawn where `n` is and then placed by `a`.
pub fn bounds_in(symbols: &SymbolBoxes, n: &Node, a: Affine) -> Option<Rect> {
Drawn::new(symbols).bounds_in(n, a)
}
/// `n` as plain art with the geometry effects in it applied, at every depth, symbol instances
/// measured by `symbols`. Raster effects stay on the art. A group, layer or intertwine without a
/// clip is rebuilt with the drawn art of each member that has one. A clip group or layer becomes a
/// group of its clipping path without strokes and the drawn art of the path's strokes, when they
/// have one. `None` when no geometry effect in `n` changes what it draws.
pub fn drawn_art(symbols: &SymbolBoxes, n: &Node) -> Option<Node> {
Drawn::new(symbols).drawn_art(n)
}
/// Whether [`drawn_art`] gives `n` art of its own, or would give one to an object in it. A symbol
/// instance in `n` counts when its symbol's art has a geometry effect.
pub fn draws_geometry(symbols: &SymbolBoxes, n: &Node) -> bool {
Drawn::new(symbols).draws_geometry(n)
}
/// One measurement: whether the art of each symbol checked so far draws geometry, by symbol name,
/// and how many more instances' art it measures with its geometry effects in the current object ([`MAX_INSTANCES`]).
struct Drawn<'s, 'd> {
symbols: &'s SymbolBoxes<'d>,
draws: RefCell<HashMap<String, bool>>,
left: Cell<usize>,
}
impl<'s, 'd> Drawn<'s, 'd> {
fn new(symbols: &'s SymbolBoxes<'d>) -> Self {
Drawn { symbols, draws: RefCell::default(), left: Cell::new(MAX_INSTANCES) }
}
/// Sets the count of instances left back to [`MAX_INSTANCES`], for the next object.
fn next(&self) -> &Self {
self.left.set(MAX_INSTANCES);
self
}
/// See [`visual_bounds`].
fn visual_bounds(&self, n: &Node) -> Option<Rect> {
match self.drawn_art(n) {
Some(art) => art.visual_bounds_with(self.symbols),
None => n.visual_bounds_with(self.symbols),
}
}
/// See [`bounds_in`].
fn bounds_in(&self, n: &Node, a: Affine) -> Option<Rect> {
if a == Affine::IDENTITY {
return self.visual_bounds(n);
}
match self.drawn_art(n) {
Some(art) => art.bounds_in(self.symbols, a, true),
None => n.bounds_in(self.symbols, a, true),
}
}
/// See [`drawn_art`].
fn drawn_art(&self, n: &Node) -> Option<Node> {
let Some(mut art) = self.own_art(n) else { return self.clip_art(n).or_else(|| self.with_drawn_members(n)) };
// The art's members can carry geometry effects of their own: type's fills and strokes, the
// members of an evaluated group, the clipping path of an evaluated clip group and the
// objects in a symbol's art.
if let Some(m) = self.clip_art(&art).or_else(|| self.with_drawn_members(&art)) {
art = m;
}
Some(art)
}
/// See [`draws_geometry`].
fn draws_geometry(&self, n: &Node) -> bool {
let mut found = false;
n.walk(&mut |c| {
found = found
|| match &c.kind {
NodeKind::SymbolInstance { symbol, .. } => !geometry_effects(c).is_empty() || self.symbol_draws(symbol),
_ => reshapes(c),
};
});
found
}
/// Whether the art of the symbol named `name` has effects and [`Self::draws_geometry`], worked
/// out once for each name.
fn symbol_draws(&self, name: &str) -> bool {
if let Some(d) = self.draws.borrow().get(name) {
return *d;
}
let d = self.symbols.has_effects(name) && self.symbols.measure_art(name, |art| self.draws_geometry(art)).unwrap_or(false);
self.draws.borrow_mut().insert(name.to_string(), d);
d
}
/// The art the geometry effects of `n` itself give it, or that a symbol instance gets from the
/// geometry effects in its symbol's art. A group or layer with fills or strokes of its own that
/// paint, around members with geometry effects, is evaluated too: its fills and strokes paint
/// the members' geometry before their effects. A blend, envelope or repeat whose objects have
/// geometry effects is evaluated as the renderer evaluates it ([`expanded_live_group`]), and
/// the objects in its evaluated art keep their geometry effects. `None` when it has none, and
/// for a symbol instance past [`MAX_INSTANCES`].
fn own_art(&self, n: &Node) -> Option<Node> {
let inner = || n.children().is_some_and(|ch| ch.iter().any(|c| self.draws_geometry(c)));
let painted = || has_own_paint(n) && inner();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(n),
NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n),
NodeKind::SymbolInstance { symbol, .. } => {
if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) {
return None;
}
let left = self.left.get().checked_sub(1)?;
self.left.set(left);
let art = self.symbols.measure_art(symbol, Arc::clone)?;
// An instance in perspective is projected by `reshape`.
reshape(n, Some(&*art)).or_else(|| outline_art(n, Some(&*art)))
}
_ if reshapes(n) => reshape(n, None),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) if inner() => {
Some(expanded_live_group(Some(self.symbols.document()), n))
}
_ => None,
}
}
/// `n`, a group, layer or intertwine without a clip, with each member that has a
/// [`Self::drawn_art`] replaced by it. `None` for any other object and when no member has one.
fn with_drawn_members(&self, n: &Node) -> Option<Node> {
if n.clips() || !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Intertwine { .. }) {
return None;
}
let drawn: Vec<(usize, Node)> = n.children()?.iter().enumerate().filter_map(|(i, c)| Some((i, self.drawn_art(c)?))).collect();
if drawn.is_empty() {
return None;
}
let mut m = n.clone();
let children = m.children_mut()?;
for (i, c) in drawn {
if let Some(slot) = children.get_mut(i) {
*slot = Arc::new(c);
}
}
Some(m)
}
/// Clip group or layer `n` as its clipping path's strokes draw it: a group of the clipping path
/// without its strokes, measured as the region it clips to, and its strokes ([`Node::clip_paint`])
/// as their [`Self::drawn_art`]. The renderer clips by the clipping path's geometry before its
/// geometry effects ([`crate::clip_outline`]) and paints the path's fills inside that region and
/// its strokes over the clipped art, unclipped. `None` for any other object, when the clipping
/// path is hidden and when its strokes have no drawn art.
fn clip_art(&self, n: &Node) -> Option<Node> {
if !n.clips() {
return None;
}
let clip = n.children()?.first().filter(|c| c.visible)?;
let strokes = self.drawn_art(&clip.clip_paint().stroke?)?;
Some(Node::group(n.id, vec![Arc::new(unstroked(clip)), Arc::new(strokes)]))
}
}
/// Clipping path `clip` without its strokes or its characters' strokes.
fn unstroked(clip: &Node) -> Node {
let mut n = clip.clone();
n.appearance.items.retain(AppearanceItem::is_fill);
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.stroke = Paint::None;
}
}
n
}
/// Whether `n` itself has a geometry effect that [`Drawn::own_art`] applies, its own or one of its
/// fill's or stroke's (a group or layer: also a Pathfinder effect; a symbol instance: only its own).
fn reshapes(n: &Node) -> bool {
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => {
has_pathfinder(n) || !geometry_effects(n).is_empty() || n.appearance.items.iter().any(|i| paints(i) && has_geometry(i.effects()))
}
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| paints(i) && has_geometry(i.effects()))
}
_ => needs_outline(n) && (!geometry_effects(n).is_empty() || has_item_geometry(n)),
}
}
/// Path or compound path `n` as a group of paths: its geometry after its own geometry effects
/// painted by its fills and strokes without geometry effects, and that geometry after the
/// geometry effects of each other fill or stroke that paints, painted by that fill or stroke, as
/// the renderer draws them. The first path is left out when only fills or strokes with geometry
/// effects paint. The paths keep `n`'s id and fill rule and carry no effects.
fn path_art(n: &Node) -> Option<Node> {
let (path, rule) = leaf_geometry(n)?;
let ctx = GeomContext::of(n);
let g = apply_geometry_with(&n.appearance.effects, &path, path.to_bezpath().bounding_box(), &ctx);
let piece = |geometry: PathData, items: Vec<AppearanceItem>| {
let mut ap: Appearance = (*n.appearance).clone();
ap.items = items;
ap.effects.clear();
for i in &mut ap.items {
i.effects_mut().clear();
}
let mut p = Node::path(n.id, geometry, ap);
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
Arc::new(p)
};
let (own, plain): (Vec<&AppearanceItem>, Vec<&AppearanceItem>) = n.appearance.items.iter().partition(|i| paints(i) && has_geometry(i.effects()));
let mut pieces = vec![];
if own.is_empty() || plain.iter().any(|i| paints(i)) {
pieces.push(piece(g.clone(), plain.into_iter().cloned().collect()));
}
let bounds = g.to_bezpath().bounding_box();
for item in own {
let geometry = apply_geometry_with(item.effects(), &g, bounds, &ctx.item(item));
pieces.push(piece(geometry, vec![item.clone()]));
}
Some(Node::group(n.id, pieces))
}
+6
View File
@@ -29,6 +29,8 @@ pub enum RasterFx {
/// instead of `color`; `None` when it is `color`.
darkness: Option<f32>,
},
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
/// as the glows AI files export measure.
OuterGlow {
mode: BlendMode,
opacity: f32,
@@ -93,6 +95,10 @@ impl RasterFx {
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
match self {
RasterFx::Rasterize(r) => r.image(bounds, line),
RasterFx::OuterGlow { .. } => {
let o = self.outset(bounds) + 0.25 * line;
bounds.inflate(o, o)
}
fx => {
let o = fx.outset(bounds);
bounds.inflate(o, o)
+107 -19
View File
@@ -7,12 +7,14 @@
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints.
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
//! them ([`image_rows`]).
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
@@ -44,11 +46,75 @@ pub fn has_gradient_copies(n: &Node) -> bool {
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
/// paints ([`outline_text`], which leaves inline graphics out).
pub fn has_item_geometry(n: &Node) -> bool {
let NodeKind::Text(t) = &n.kind else { return false };
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
match &n.kind {
NodeKind::Text(t) => {
t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()))
}
NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image),
_ => false,
}
}
/// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or
/// Outline Object)?
fn gives_shape(e: &Effect) -> bool {
e.visible
&& matches!(e.id.as_str(), "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" | "path.outlineObject")
}
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
/// that gives it a shape ([`gives_shape`])?
fn shapes_image(item: &AppearanceItem) -> bool {
crate::paints(item) && item.effects().iter().any(gives_shape)
}
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
/// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes
/// its shape from the image's box, and Outline Object gives the image's frame. The others paint
/// nothing.
fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
items
.iter()
.filter(|i| shapes_image(i))
.map(|i| {
let mut i = i.clone();
let fx = i.effects_mut();
let first = fx.iter().position(gives_shape).unwrap_or(0);
fx.drain(..first);
i
})
.collect()
}
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
/// them. Empty for anything but an image with such fills or strokes.
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
return vec![];
}
let Some(art) = reshape(n, None) else { return vec![] };
let mut out = vec![];
art.walk(&mut |c| {
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
let Some(g) = c.stroke_path() else { return };
let ctx = GeomContext::of(c);
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
let fx = item.effects();
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
crate::apply_geometry_bez(fx, &g, g.bounding_box(), &ctx.item(item))
} else {
g.clone()
};
out.push((shape, *rule, item.clone()));
}
});
out
}
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
@@ -186,11 +252,13 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// `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. Type's own fills and strokes keep their effects on its whole outline. The result is a
/// group with `n`'s id, name, visibility, transparency, opacity mask and remaining (raster)
/// effects; `None` when `n` has no visible geometry effect, no perspective and is not type with
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
/// symbol instance without `symbol_art`).
/// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own
/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and
/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
/// 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() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
return None;
@@ -200,14 +268,17 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
/// Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
/// when it has neither.
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
if has_gradient_copies(n) {
return as_art(n, symbol_art);
}
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
let own_paint = match n.kind {
NodeKind::Text(_) => !n.appearance.items.is_empty(),
_ => has_item_geometry(n),
};
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
return None;
}
@@ -220,12 +291,28 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let mut clip = Node::path(n.id, frame, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node::new(n.id, n.kind.clone());
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
let clipped = || {
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true })
};
// The fills and strokes that paint a shape, as paths along the frame below and above
// the image.
let (below, above) = n.appearance.split_contents();
let (below, above) = (image_rows(below), image_rows(above));
if below.is_empty() && above.is_empty() {
clipped()
} else {
let rows = |items: Vec<AppearanceItem>| {
(!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() })))
};
// Without geometry effects, the image is not clipped.
let image = if fx.is_empty() { image.clone() } else { clipped() };
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art(n, symbol_art)?,
};
@@ -234,7 +321,8 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
}
// The art is a group with `n`'s id (a clip group for an image).
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
// group).
let mut out = art;
out.name = n.name.clone();
out.visible = n.visible;
+79 -8
View File
@@ -143,6 +143,13 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
facets(id, &path, rule, b, p).map(|_| ())
}
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
/// [`validate_solid`].
pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
@@ -212,8 +219,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
}
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order.
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
let p = crate::merged_params(&fx.id, &fx.params);
@@ -229,12 +240,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
}
return Some(out);
}
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
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, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
.iter()
.enumerate()
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
.map(|(_, i)| {
let (area, rule) = region(path, rule, i);
(*i, area, rule)
})
.collect();
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
// and Inflate's grid reaches over the strokes' outlines.
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
let built = if fx.id == INFLATE {
// One eye for every puff, as far away as the highest needs.
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
let mut solids = vec![];
for ((item, _, _), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) => solids.push((item, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
// 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.
Err(_) => return None,
}
}
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, _)| {
let (_, opacity, blend) = paint_of(item);
opacity >= 1.0 && blend == BlendMode::Normal
});
let surfaces: Vec<(Vec<f64>, Node)> = solids
.into_iter()
.map(|(item, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item);
(depths, surface(n, f, &paint, opacity, blend, colored))
})
.collect();
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
// opacity mask, and then it stays whole.
let plain = surfaces.iter().all(|(depths, s)| {
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
});
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
}
let mut faces = vec![];
for (depths, s) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
}
}
// A stable sort: faces at the same depth keep the order of the appearance.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+19
View File
@@ -127,6 +127,25 @@ fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
}
#[test]
fn expand_stroke_region_of_a_round_line_stays_compact() {
use std::f64::consts::PI;
use vectorcraft_geom::{FillRule, PathData, Point, SubPath};
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round);
let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st);
let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}");
let area = region.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}");
// Extra colinear anchors along the spine must not multiply outline anchors (#3446).
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st);
let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
#[test]
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
let l = line(0.0, 100.0);
+228 -7
View File
@@ -1,6 +1,6 @@
use serde_json::json;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
use super::*;
@@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red, shaded: never brighter, never another hue.
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
// hue.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
@@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
// Extrude & Bevel paints one surface with the top fill.
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
let mut n = square_node();
n.appearance.effects.push(fx(EXTRUDE, json!({})));
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
}
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
fn stroked(b: &BezPath) -> Node {
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
}
/// A `width` × 100 pt rectangle at (100, 100), stroked.
fn stroked_rect(width: f64) -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((100.0 + width, 100.0));
b.line_to((100.0 + width, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
stroked(&b)
}
/// Is `face` in the stroke's blue (rather than the fill's red)?
fn blue(face: &Node) -> bool {
let [r, _, b] = fill_rgb(face);
b > r
}
/// The bounds of the faces' own paths (without their seam strokes).
fn extent(faces: &[&Node]) -> Rect {
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
}
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
}
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
/// fill's, and they are drawn in the stroke's paint.
#[test]
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
for id in [EXTRUDE, INFLATE] {
let mut n = stroked_rect(100.0);
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
let art = solid_art(&n).unwrap();
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
assert!(!strokes.is_empty(), "{id}: the stroke draws");
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
// Its own blue, shaded: never brighter, never another hue.
for f in &strokes {
let [r, g, b] = fill_rgb(f);
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
}
}
}
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
/// drawn below the fill's.
#[test]
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
// caps; the mean of its outline's points is not the mean of the stroke's.
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((400.0, 100.0));
b.line_to((100.0, 200.0));
b.close_path();
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
let mut n = stroked(&b);
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let flat = params["depth"] == json!(0);
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
// The stroke's cap is a ring: its outline and the hole the fill shows through.
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
if !flat {
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
}
}
}
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
#[test]
fn a_stroked_open_path_builds_a_ribbon() {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((200.0, 100.0));
b.line_to((200.0, 200.0));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
assert!(faces.iter().all(|f| blue(f)), "{id}");
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
}
}
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
/// still draws.
#[test]
fn a_stroke_past_the_limits_is_left_out() {
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..1500)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
Point::new(200.0 + r * c, 200.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
n.appearance.effects.push(fx(id, json!({})));
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
}
}
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
#[test]
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
// 1 pt stroke, so the stroke stays flat.
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
let mut n = stroked_rect(length);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.width = width;
}
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
}
}
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
#[test]
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.align = vectorcraft_doc::StrokeAlign::Outside;
}
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
assert!(!blue(fill_back) && blue(stroke_back));
let fill = fill_back.path_data().unwrap().bounds().unwrap();
// The ring's inner contour: the smaller of its two.
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
}
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
/// opacity applies to the stroke as a whole.
#[test]
fn a_translucent_stroke_is_a_surface_of_its_own() {
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.opacity = opacity;
s.blend = blend;
}
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let art = solid_art(&n).unwrap();
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 2);
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
}
}
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
#[test]
fn a_masked_surface_under_a_colored_light_stays_whole() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
}
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
let art = solid_art(&n).unwrap();
let drawn = art.children().unwrap();
assert_eq!(drawn.len(), surfaces, "{lights}");
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
}
}
#[test]
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
let mut n = square_node();
@@ -230,9 +448,9 @@ 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, "perspectiveAngle": 0})));
// Straight on, the extrusion shows its front: the offset square.
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}");
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
let art = solid_art(&n).unwrap();
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
@@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
}
let (groups, clips, paths) = count(&art);
assert!(paths > 1000, "exercise a tessellated surface");
assert_eq!(clips, 1);
assert_eq!(groups, 7, "group count must not grow with triangle count");
// The fill's solid and the stroke's, each one composite with its own clip.
let surfaces = art.children().unwrap().len();
assert_eq!(surfaces, 2);
assert_eq!(clips, surfaces);
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
}
+2
View File
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
[dependencies]
# Which paths stored in documents may be opened.
craft-docpath = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
+76 -25
View File
@@ -15,7 +15,9 @@
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
//! paint below the members and which above ([`Appearance::contents_at`], moved by
//! `appearance.moveItem {from: "contents"}`).
//! `appearance.moveItem {from: "contents"}`). An image's own fills and strokes paint only the
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
//! image as its Contents row says; new ones go right below that row.
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
@@ -35,8 +37,8 @@ pub fn specs() -> Vec<CommandSpec> {
"appearance.addFill",
"Add New Fill",
["Window", "Appearance"],
None,
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
Some("Cmd+/"),
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -44,8 +46,8 @@ pub fn specs() -> Vec<CommandSpec> {
"appearance.addStroke",
"Add New Stroke",
["Window", "Appearance"],
None,
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
Some("Cmd+Alt+/"),
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -117,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
has_doc,
copy_from
),
@@ -216,7 +218,7 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
Some(Value::Null) => Ok(ItemTarget::Top),
Some(v) => v
.as_u64()
.map(|i| ItemTarget::Item { index: i as usize, explicit: true })
.map(|i| ItemTarget::Item { index: index(i), explicit: true })
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
// The active item is a row of the first selected object's own stack.
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
@@ -381,8 +383,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
}
/// The whole number of 0 or more that `key` holds, read with [`index`].
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
@@ -408,6 +411,9 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
let defaults = s.paint.clone();
// Where the new item went in on the active item's object, when it is an image.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut inserted = None;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -429,11 +435,23 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
} else {
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
};
n.appearance.items.push(item);
if matches!(n.kind, NodeKind::Image(_)) {
// On an image it goes right below the Contents row (the image).
let k = n.appearance.contents_at();
n.appearance.items.insert(k, item);
n.appearance.set_contents_at(k + 1);
if active == Some(*id) {
inserted = Some(k);
}
} else {
n.appearance.items.push(item);
}
}
}
Ok(())
})?;
// The rows above the new item move up by one.
s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a))));
ok()
}
@@ -478,7 +496,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
ap.rebase_gradients(f, t);
}
// Only groups, layers and type have a Contents (Characters) row to keep a slot for.
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
if to.contents_label().is_none() {
ap.contents_index = None;
}
@@ -530,6 +548,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.setItem")?;
super::paint::check_keep_model(p, "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
@@ -586,7 +605,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let mut v = match p.get("indices") {
Some(a) => a
.as_array()
.and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::<Option<Vec<_>>>())
.and_then(|a| a.iter().map(|x| x.as_u64().map(index)).collect::<Option<Vec<_>>>())
.filter(|v| !v.is_empty())
.ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?,
None => vec![index_param(p, "index", cmd)?],
@@ -717,7 +736,7 @@ fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
n.appearance.set_contents_at(to);
slot.get_or_insert(n.appearance.contents_at());
}
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row"))?;
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
Ok(count_changed(d, &before, ids))
})?;
Ok(json!({ "contents": slot }))
@@ -739,11 +758,13 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
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 range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
let selected = match ids_param(p, "ids") {
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
@@ -757,12 +778,38 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
};
targets.retain(|id| *id != src_id);
let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?);
let range = range.filter(|(id, _, _)| targets.contains(id));
// Character styles cannot represent object transparency. Refuse before even changing
// paint defaults; applying it to the object would also alter the unselected characters.
if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) {
let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
return Err(bad(
C,
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
));
}
}
if !reverse {
picked.to_defaults(s, &attrs.appearance);
}
if targets.is_empty() || attrs.is_empty() {
return Ok(json!({ "ids": [] }));
}
if let Some((id, start, end)) = range {
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let placed = picked.placed(Some(t.local_bounds()));
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
}
super::typecmd::refresh_bounds(t);
Ok(count_changed(d, &before, &[id]))
})?;
return Ok(json!({ "ids": [id.0] }));
}
// When every target comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
@@ -824,6 +871,22 @@ impl Picked {
}
}
/// Copy character attributes and the enabled paint attributes through the same path for
/// whole text objects and highlighted character ranges.
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(st, placed, &a.appearance);
}
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
/// stack instead. Type takes the paints on its characters, and character and paragraph
/// attributes from type.
@@ -843,19 +906,7 @@ impl Picked {
let placed = self.placed(Some(t.local_bounds()));
let text = self.text.as_ref();
for r in &mut t.runs {
if let Some((cs, _)) = text.filter(|_| a.character) {
// The character attributes; the paints follow the appearance attributes.
let st = &mut r.style;
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(&mut r.style, &placed, ap);
self.run(&mut r.style, &placed, a);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
// The source's first paragraph's attributes, on every paragraph.
+233 -34
View File
@@ -6,17 +6,18 @@
//! `Node::geometric_bounds` assumes for instances, and each instance's `xf` scales it back. The
//! natural size is kept in `Document::unknown["symbolSizes"]` so new instances come out right.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, Brush};
use vectorcraft_brush::{self as brush, Bristle, Brush, BrushKind};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::names_param;
use super::swatch::{anchor, names_param};
use super::*;
use crate::EngineError;
@@ -30,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
// ----- brushes -----
cmd!(query "brush.list", "Brushes", [], None, "{} → {brushes: [{name, type}], current}", has_doc, brush_list),
cmd!(query "brush.get", "Brush Definition", [], None, "{name} → the brush definition (JSON)", has_doc, brush_get),
cmd!(
query "brush.selectionArt",
"Brush Art",
[],
None,
"{ids?|id?} → {art: the object brush.new takes as a new scatter or art brush's art and as a new pattern brush's side tile (several objects become one group), or null when nothing is selected or ids name no object}",
has_doc,
brush_selection_art
),
cmd!(
"brush.apply",
"Apply Brush",
@@ -62,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Brush…",
[],
None,
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush; a bristle brush name, with the number a taken name gets, has at most 31 characters), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
has_doc,
brush_new
),
@@ -75,6 +85,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_delete
),
cmd!(
"brush.move",
"Move Brush",
[],
None,
"{name? | names?: [brush names of one type, in order], to?: index among the brushes of that type in brush.list order (they go before the brush of that type now there that isn't moving; default: after the last one), type?: calligraphic|scatter|art|pattern|bristle (the type they go into; default: theirs)} reorder brushes within their type, as one undo step (none when the order stays as it was); brushes of different types, or a type that isn't theirs, are refused (brushes move only within their type) → {moved}",
has_doc,
brush_move
),
cmd!(
"brush.duplicate",
"Duplicate Brush",
@@ -84,12 +103,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_duplicate
),
cmd!(
query "brush.unused",
"Select All Unused",
[],
None,
"{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles and graphic styles count, and so do strokes in the art of a brush that is used",
has_doc,
brush_unused
),
cmd!(
"brush.options",
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@@ -210,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbolism Tool",
[],
None,
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}",
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
has_doc,
symbol_adjust
),
@@ -268,6 +296,12 @@ fn brush_get(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(b).unwrap_or_default())
}
/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]).
fn brush_selection_art(s: &mut Session, p: &Value) -> Result<Value> {
let art = selection_art(s, p)?;
Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) }))
}
fn brush_apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.apply";
let name = name_param(p, C)?.to_string();
@@ -348,38 +382,100 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
})
}
/// The definition fields that `params` change field by field when the brush has them as objects:
/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only
/// `start` keeps `end`.
const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"];
fn merge(base: &mut Value, patch: &Value) {
if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) {
for (k, v) in pt {
b.insert(k.clone(), v.clone());
match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) {
(Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => {
for (f, fv) in fields {
inner.insert(f.clone(), fv.clone());
}
}
_ => {
b.insert(k.clone(), v.clone());
}
}
}
}
}
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
/// Sets brush `to` on the strokes in `items` that paint with brush `from`.
fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) {
for it in items {
if let AppearanceItem::Stroke(st) = it
&& st.brush.as_deref() == Some(from)
{
st.brush = to.map(str::to_string);
}
}
}
/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`.
fn paints_with(n: &Node, name: &str) -> bool {
let mut hit = false;
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name)));
});
hit
}
/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that
/// hold such a stroke, and those that hold them, are copied.
fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) {
set_stroke_brush(&mut n.appearance.items, from, to);
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& paints_with(&mask.art, from)
{
set_brush_in(Arc::make_mut(&mut mask.art), from, to);
}
for c in n.children_mut().into_iter().flatten() {
if paints_with(c, from) {
set_brush_in(Arc::make_mut(c), from, to);
}
}
}
/// Returns the art a brush is made of ([`Brush::art`]) for editing.
fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> {
use vectorcraft_brush::BrushKind;
match &mut b.kind {
BrushKind::Scatter(s) => vec![&mut s.art],
BrushKind::Art(a) => vec![&mut a.art],
BrushKind::Pattern(p) => {
std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect()
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art
/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern
/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`.
fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) {
let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art));
let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()));
for n in trees.chain(patterns.flat_map(|p| &mut p.art)) {
if paints_with(n, from) {
set_brush_in(Arc::make_mut(n), from, to);
}
}
for g in &mut d.graphic_styles {
set_stroke_brush(&mut g.appearance.items, from, to);
}
for n in lib.iter_mut().flat_map(brush_art_mut) {
set_brush_in(n, from, to);
}
}
fn save_library(s: &mut Session, label: &str, mut lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
s.edit(label, |d, _| {
brush::store(d, &lib);
for (from, to) in renames {
let ids: Vec<NodeId> = {
let mut v = vec![];
d.walk(|n| {
if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(from.as_str()))) {
v.push(n.id);
}
});
v
};
for id in ids {
if let Some(n) = d.node_mut(id) {
for it in &mut n.appearance.items {
if let AppearanceItem::Stroke(st) = it
&& st.brush.as_deref() == Some(from.as_str())
{
st.brush = to.clone();
}
}
}
}
rename_brush_strokes(d, &mut lib, from, to.as_deref());
if brush::current(d).as_deref() == Some(from.as_str()) {
match to {
Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)),
@@ -387,6 +483,7 @@ fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(Strin
};
}
}
brush::store(d, &lib);
Ok(())
})
}
@@ -404,6 +501,9 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
let name = brush::unique_name(&lib, &base_name);
if ty == "bristle" {
bristle_name_fits(C, &name)?;
}
let mut v = json!({"name": name, "type": ty});
if let Some(params) = p.get("params") {
merge(&mut v, params);
@@ -424,11 +524,18 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name }))
}
/// A brush definition from command params, its values kept within their ranges.
/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters.
fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> {
let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS);
if count > max {
return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}")));
}
Ok(())
}
/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]).
fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
b.sanitize();
Ok(b)
Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))
}
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -444,6 +551,54 @@ fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their
/// type in library order: before the first brush of that type at or after `to` that isn't moving,
/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type`
/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step.
fn brush_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.move";
const WITHIN: &str = "brushes move only within their type";
let names = names_param(p, C)?;
check_numbers(p, &["to"], C)?;
let to = match p.get("to").filter(|v| !v.is_null()) {
Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?),
None => None,
};
let old = brush::library(&s.doc()?.doc);
let mut lib = old.clone();
// `names_param` refuses an empty list, so the loop sets `kind`.
let mut kind = "";
for name in &names {
let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
if !kind.is_empty() && kind != b.kind.type_id() {
return Err(bad(C, WITHIN));
}
kind = b.kind.type_id();
}
if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) {
return Err(bad(C, WITHIN));
}
let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect();
let before = anchor(&same, to, &names, |b| &b.name);
let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len());
let mut items = vec![];
for name in &names {
if let Some(i) = lib.iter().position(|b| &b.name == name) {
items.push(lib.remove(i));
}
}
let at = match before {
Some(a) => lib.iter().position(|b| b.name == a),
None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1),
};
let at = at.unwrap_or(first).min(lib.len());
lib.splice(at..at, items);
if lib != old {
save_library(s, "Move Brush", lib, &[])?;
}
Ok(json!({ "moved": names.len() }))
}
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
/// without `names`, names the copy.
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
@@ -464,6 +619,44 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
}
/// Adds the names of the brushes the strokes of `items` paint with to `out`.
fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec<String>) {
for it in items {
if let AppearanceItem::Stroke(st) = it
&& let Some(b) = &st.brush
{
out.push(b.clone());
}
}
}
/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to
/// `out`.
fn art_brushes(n: &Node, out: &mut Vec<String>) {
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out));
}
/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph
/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush
/// that a counted stroke paints with.
fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let lib = brush::library(d);
let mut todo = vec![];
let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art));
trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo));
d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo));
let mut used = BTreeSet::new();
while let Some(name) = todo.pop() {
if !used.contains(&name) {
lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo));
used.insert(name);
}
}
let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect();
Ok(json!({ "names": names }))
}
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.options";
let name = name_param(p, C)?.to_string();
@@ -478,6 +671,10 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
if lib.iter().any(|b| &b.name == nn) {
return Err(bad(C, format!("a brush named `{nn}` exists")));
}
// A `newName` that only trims the white space around the saved name is taken at any length.
if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() {
bristle_name_fits(C, nn)?;
}
v["name"] = json!(nn);
} else {
v["name"] = json!(name);
@@ -1104,13 +1301,14 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) {
return Err(bad(C, format!("unknown tool `{tool}`")));
}
super::paint::check_keep_model(p, C)?;
let pts = points(p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
let alt = bool_or(p, "alt", false);
let st = s.doc()?;
let color = match p.get("color").and_then(color_value) {
Some(c) => c,
Some(c) => super::paint::new_color(s, p, c),
None => s.paint.fill.color().unwrap_or(Color::BLACK),
};
let style_paint = match str_param(p, "style") {
@@ -1133,7 +1331,8 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
v.retain(|id| d.is_editable(*id));
let editable = d.editable();
v.retain(|id| editable(*id));
v
};
let label = match tool.as_str() {
+132 -83
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder Regions",
[],
None,
"{ids?|id?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
"{ids?|id?, gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Live Paint"],
Some("Cmd+Alt+X"),
"{ids?|id?} build a Live Paint group from the selected paths → {id, faces, edges}",
"{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
has_selection_or_ids,
lp_make
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge",
["Object", "Live Paint"],
None,
"{ids?|id?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
"{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
has_multi_or_ids,
lp_merge
),
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket",
[],
None,
"{group?: id | ids?|id?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}",
has_doc,
lp_fill
),
@@ -232,10 +232,16 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
paint_from(s, v)
}
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
Some(v) => color_value(v).map(|c| Some(Paint::solid(super::paint::new_color(s, p, c)))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
}
}
/// Gap Detection length in points, bounded like Live Paint's custom gap size.
fn gap_param(p: &Value) -> f64 {
let gap = f64_or(p, "gap", 0.0);
if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 }
}
fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shapeBuilder.merge";
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
@@ -245,12 +251,24 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let erase = bool_or(p, "erase", false);
// Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let fill = fill_param(s, p, C)?;
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true);
let swatches = match p.get("pickColorFrom") {
None | Some(Value::Null) => false,
Some(v) => match v.as_str() {
Some("artwork") => false,
Some("swatches") => true,
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
},
};
let current_fill = s.paint.fill.clone();
let fill = match fill_param(s, p, C)? {
None if swatches => Some(current_fill.clone()),
f => f,
};
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots, erase);
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
@@ -268,7 +286,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::new(&map.shapes);
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an
// area only lines enclose) of the front-most object and the current fill.
@@ -311,7 +329,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = !leaf.appearance.fill_paint().is_none();
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
@@ -366,7 +384,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::new(d, &roots, true);
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
@@ -501,8 +519,12 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
}
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them
/// as Merge does; returns (group, faces, edges).
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) {
return merge_live(d, sel, roots);
}
let mut sources = vec![];
for r in roots {
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
@@ -523,71 +545,78 @@ fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its
/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which
/// are merged; returns (group, faces, edges).
fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
let target = roots
.iter()
.copied()
.find(|r| d.node(*r).is_some_and(is_live_paint))
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
})
.collect();
let mut sources = source_nodes(&old);
for r in roots {
if *r != target {
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
}
}
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
let options = merged_gap_options(&groups);
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
// The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic
// style link and the rest; its children are made again and its Gap Options set to the merged ones.
let mut g = old;
if let Some(ch) = g.children_mut() {
*ch = children;
}
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
}
}
for (pt, app) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
}
}
let mut merged = Beside::replacing(d, roots);
for r in roots.iter().filter(|r| **r != target) {
if d.node(*r).is_some() {
merged.put(d, *r, vec![])?;
}
}
merged.put(d, target, vec![g])?;
merged.finish(d)?;
sel.set([target]);
Ok((target, nf, ne))
}
fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| {
let target = roots
.iter()
.copied()
.find(|r| d.node(*r).is_some_and(is_live_paint))
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
})
.collect();
let mut sources = source_nodes(&old);
for r in &roots {
if *r != target {
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
}
}
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
let options = merged_gap_options(&groups);
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
let mut g = Node::group(target, children);
g.name = old.name.clone();
g.opacity = old.opacity;
g.blend = old.blend;
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
}
}
for (pt, app) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
}
}
let mut merged = Beside::replacing(d, &roots);
for r in roots.iter().filter(|r| **r != target) {
if d.node(*r).is_some() {
merged.put(d, *r, vec![])?;
}
}
merged.put(d, target, vec![g])?;
merged.finish(d)?;
sel.set([target]);
Ok((target, nf, ne))
})?;
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
@@ -662,6 +691,12 @@ fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
}
/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide,
/// as `livePaint.info` lists them. None when `group` is not a Live Paint group.
pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> {
if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] }
}
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.gapOptions";
let detect = match p.get("detect").filter(|v| !v.is_null()) {
@@ -723,6 +758,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
@@ -734,13 +770,24 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
g
}
None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) {
Some(g) => g,
None => make_live(d, sel, &roots)?.0,
},
None => {
let live_at = |face: fn(&Node, Point) -> Option<&Arc<Node>>| {
roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some()))
};
match (live_at(b::visible_face_at), live_at(face_at)) {
(Some(g), _) => g,
// At a point on a hidden face of a Live Paint group among the objects, the command returns an error
// and does not merge the objects.
(None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())),
(None, None) => make_live(d, sel, &roots)?.0,
}
}
};
let fid =
d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
let fid = d
.node(gid)
.and_then(|n| b::visible_face_at(n, pt))
.map(|f| f.id)
.ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone());
Ok((gid, fid))
})?;
@@ -752,6 +799,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
if !(0.0..=1000.0).contains(&width) {
@@ -760,7 +808,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
let edge = s.edit("Live Paint Bucket", |d, _| {
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
let eid = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
e.appearance.set_stroke(paint.clone());
if let Some(st) = e.appearance.stroke_mut() {
@@ -881,13 +929,14 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
h: height,
radii: [radius; 4],
kinds: Default::default(),
radii_relative: false,
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![] }
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false }
}
};
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
+1 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
use super::*;
+58 -9
View File
@@ -30,6 +30,10 @@ pub const EMF: &str = "image/emf";
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
/// beside, or instead of, a PDF (#1499).
pub const EPS: &str = "application/postscript";
/// A `.ai` or `.ait` file whose bytes are a PDF, copied in a file manager ([`file_flavour`]): Paste
/// reads it as `document.open` does, from its editing data. The type is VectorCraft's own: no
/// system clipboard carries it, and Paste asks for [`PDF`] to get it.
pub const AI: &str = "application/x-ai";
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
/// (word processors offer a picture of copied text too).
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
@@ -71,8 +75,9 @@ 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. 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.
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file is [`AI`] when its bytes are a PDF (it
/// pastes when PDF is asked for) and 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 {
@@ -83,7 +88,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
"pdf" => (PDF, PDF),
"ps" => (EPS, EPS),
"eps" => (EPS, EPS),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, AI),
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
"emf" | "wmf" => (EMF, EMF),
_ if f.raster => (BITMAP, f.mime),
@@ -122,6 +127,13 @@ pub struct Flavour {
const COPY_PNG_SIDE: f64 = 4096.0;
/// Most bytes of text `clipboard.importText` takes.
const MAX_TEXT: usize = 1 << 20;
/// Most bytes of images the SVG markup may embed and still go out as plain text. A paste reads the
/// clipboard's text first (eframe reads it on every paste key; on Linux and macOS it is the one
/// format the app writes, and checking that the clipboard still holds our copy reads it again), so
/// a placed photo in it made each paste move megabytes of base64 through the system clipboard on
/// the UI thread, and on a GNOME desktop the app stopped responding (#2213). A copy with more image
/// data than this offers its SVG under its own type and its PNG, not as text.
const COPY_TEXT_IMAGE_BYTES: usize = 1 << 20;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -157,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard Formats",
[],
None,
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg when the images it embeds total at most 1 MiB), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
always,
flavours
),
@@ -184,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load PDF into Clipboard",
[],
None,
"{dataBase64, page?: 1, password? (an encrypted PDF), center?: [x, y]} replace the clipboard with the objects of one PDF page (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use; a .ai saved without PDF compatibility: every object of its editing data, as document.open reads it, with or without editingData), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
has_doc,
import_pdf
),
@@ -202,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load EPS into Clipboard",
[],
None,
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use; a hidden layer's objects hidden), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
has_doc,
import_eps
),
@@ -263,12 +275,17 @@ impl Clipboard {
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// Bytes of the encoded images the objects show (what their SVG embeds as base64).
fn image_bytes(&self) -> usize {
self.images.values().fold(0usize, |sum, b| sum.saturating_add(b.bytes.len()))
}
/// 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 text = svg || self.nodes.iter().all(type_only);
let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only);
// Art without an area (a lone guide-like line) has no page or picture.
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()
@@ -424,6 +441,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "the text is empty"));
}
let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null));
t.system_layout = s.doc()?.doc.setup.system_layout;
crate::cmd::typecmd::refresh_bounds(&mut t);
let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
@@ -452,8 +470,19 @@ fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
/// opened (command `cmd`).
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
import_loaded(s, p, cmd, loaded)
}
/// Replace the clipboard with every object of `loaded`, a file read as `document.open` reads it
/// (command `cmd`). The layers are left out, and the objects of a hidden layer come in hidden.
fn import_loaded(s: &mut Session, p: &Value, cmd: &str, mut loaded: fileio::Loaded) -> Result<Value> {
loaded.doc.drop_edit_modes();
for layer in loaded.doc.layers.iter_mut().filter(|l| !l.visible) {
for object in std::sync::Arc::make_mut(layer).children_mut().into_iter().flatten() {
std::sync::Arc::make_mut(object).visible = false;
}
}
let clip = Clipboard::from_document(&loaded.doc);
if clip.is_empty() {
return Err(bad(cmd, "the picture has no objects"));
@@ -469,8 +498,28 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => 1,
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(C, "page must be a whole number from 1"))? as usize,
};
let editing = match p.get("editingData").filter(|v| !v.is_null()) {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(C, "editingData must be true or false"))?,
};
let opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() };
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
// A copied .ai file pastes every object of its editing data, as document.open reads the file.
// When document.open would not use that copy, the file pastes its page, as a PDF does.
if editing
&& let Ok(loaded) = fileio::load_with("Clipboard.ai", &bytes, &opts)
&& (loaded.editing_data || loaded.restored)
{
return import_loaded(s, p, C, loaded);
}
let (mut doc, warnings) = match fileio::page_document(&bytes, page - 1, &opts) {
// For a .ai saved without PDF compatibility, the clipboard takes every object of its
// editing data, as document.open reads it.
Err(e) if fileio::is_placeholder(&e) => {
let loaded = fileio::load_with("Clipboard.ai", &bytes, &opts).map_err(|e| bad(C, e.to_string()))?;
return import_loaded(s, p, C, loaded);
}
read => read.map_err(|e| bad(C, e.to_string()))?,
};
doc.drop_edit_modes();
let clip = Clipboard::from_document(&doc);
if clip.is_empty() {
+2 -1
View File
@@ -142,7 +142,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "colorTheme.addToSwatches";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let t = &s.prefs.color_themes[find(s, name, C)?];
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
// The theme's colors keep their model, in a CMYK document too.
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>(), "keepModel": true });
let r = s.execute("swatch.newGroup", &params)?;
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
}
+9 -5
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), 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, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), 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?, keepModel?: bool (as paint.setFill), 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, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), 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
),
@@ -214,6 +214,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
h: r.height(),
radii: [radius; 4],
kinds: Default::default(),
radii_relative: false,
xf: rect_transform(r, p, "shape.rectangle")?,
};
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
@@ -232,7 +233,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0);
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![] };
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
}
@@ -493,6 +494,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let x = f64_req(p, "x", "text.create")?;
let y = f64_req(p, "y", "text.create")?;
super::paint::check_keep_model(p, "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
@@ -504,6 +506,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
t.area.fit = new_area_fit(s, a, "text.create")?;
}
t.system_layout = s.doc()?.doc.setup.system_layout;
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
@@ -573,8 +576,9 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraf
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
}
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
/// defaults, filled with the current fill unless that is None or white (black then).
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
/// filled with the current fill unless that is None or white (black then).
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
let mut style = CharStyle::default();
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
@@ -587,7 +591,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
style.font_style = f.to_string();
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => vectorcraft_color::Paint::solid(c),
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
};
+2 -1
View File
@@ -130,7 +130,8 @@ fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
}
}
out.dedup();
out.retain(|id| doc.is_editable(*id));
let editable = doc.editable();
out.retain(|id| editable(*id));
Ok(out)
}
+4 -1
View File
@@ -165,11 +165,13 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
};
let o = dimension_of(&st.doc, from)?.options;
let mut ids = vec![];
let editable = st.doc.editable();
st.doc.walk(|n| {
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
if n.extras().dimension.is_some() && editable(n.id) {
ids.push(n.id);
}
});
drop(editable);
s.edit("Apply to All", |doc, _| {
for id in &ids {
let mut dim = dimension_of(doc, *id)?;
@@ -351,6 +353,7 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
};
let mut text = TextObject::point(Point::ZERO, &label, style);
text.para.justify = vectorcraft_doc::Justify::Center;
text.system_layout = doc.setup.system_layout;
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());
+55 -28
View File
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
@@ -300,7 +300,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
return Err(bad(C, "left/right must be non-negative widths"));
}
let adjust = bool_or(p, "adjustAdjoining", false);
let adjust = switch_or(p, "adjustAdjoining", false, C)?;
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
// history stay as they are.
let mut pos = 0;
@@ -405,7 +405,7 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Array(a)) => {
let mut pts = vec![];
for q in a {
let v: Vec<f64> = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let v: Vec<f64> = q.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()).unwrap_or_default();
if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 {
return Err(bad(C, "points must be [t, left, right] with non-negative widths"));
}
@@ -455,7 +455,8 @@ fn samples_of(p: &Value, key: &str) -> Option<Vec<Sample>> {
/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside
/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images,
/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds
/// and what it is. Hidden and locked objects, and guides, are neither.
/// and what it is. Hidden and locked objects, the art on a locked artboard, and guides are
/// neither.
#[derive(Default)]
struct Targets {
paths: Vec<(NodeId, Rect)>,
@@ -484,19 +485,21 @@ impl Targets {
/// The targets under `roots` (the selection), or with none every visible object.
fn of(d: &Document, roots: &[NodeId]) -> Self {
let mut t = Self::default();
let locks = d.locked_artboards();
if roots.is_empty() {
d.layers.iter().for_each(|l| t.visit(l));
d.layers.iter().for_each(|l| t.visit(l, &locks));
return t;
}
let editable = |id| d.is_editable_with(&locks, id);
for r in roots {
let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue };
if !d.is_editable(*r) {
if !editable(*r) {
continue;
}
// Inside a graph, an envelope, a repeat or a blend: that object is what is left alone.
match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) {
Some((a, what)) => t.block(a, what),
None => t.visit(n),
None => t.visit(n, &locks),
}
}
t
@@ -510,7 +513,7 @@ impl Targets {
}
}
fn visit(&mut self, n: &Node) {
fn visit(&mut self, n: &Node, locks: &vectorcraft_doc::LockedArtboards) {
if !n.visible || n.locked {
return;
}
@@ -525,7 +528,8 @@ impl Targets {
self.paths.push((n.id, b));
}
}
_ => n.children().into_iter().flatten().for_each(|c| self.visit(c)),
// The objects of a layer on a locked artboard, members and all, are left out.
_ => n.children().into_iter().flatten().filter(|c| !(n.is_layer() && locks.holds(c))).for_each(|c| self.visit(c, locks)),
}
}
}
@@ -628,9 +632,17 @@ impl LiquifyStroke {
}
}
/// The number keys of `object.liquify`. [`LiquifyParams::from_json`] reads a value that is not a
/// number as the key left out.
const LIQUIFY_NUMBERS: &[&str] =
&["diameter", "width", "height", "angle", "intensity", "detail", "simplify", "rate", "complexity", "horizontal", "vertical"];
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.liquify";
check_numbers(p, LIQUIFY_NUMBERS, C)?;
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
// `LiquifyParams::from_json` uses a key's default for a value that is not true or false.
check_switches(p, &["usePressure", "simplifyOn", "affectAnchors", "affectIn", "affectOut"], C)?;
let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
@@ -697,9 +709,10 @@ fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Resul
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.puppetWarp";
check_numbers(p, &["expand"], C)?;
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
let rest = bool_or(p, "rest", false);
let rest = switch_or(p, "rest", false, C)?;
if (pins.is_empty() && !rest) || pins.len() != moved.len() {
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
}
@@ -711,7 +724,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
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()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(Value::Array(a)) if a.len() == pins.len() && a.iter().all(|v| v.is_null() || v.is_number()) => {
a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect()
}
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
@@ -752,6 +767,7 @@ pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["expand"], "object.puppetWarp.pins")?;
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
@@ -794,9 +810,10 @@ pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> R
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.set";
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
let g = old.merged(p).map_err(|e| bad(C, e))?;
let reproject = switch_or(p, "reproject", false, C)?;
// When the document holds this grid already, no object moves, and the document and its undo
// history stay as they are.
if grid_kept(&s.doc()?.doc, &g) {
@@ -819,7 +836,12 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
}
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
let want = p.get("visible").and_then(Value::as_bool);
const C: &str = "perspective.grid.show";
// A missing or null `visible` toggles the grid.
let want = match p.get("visible") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "visible", false, C)?),
};
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
Ok(json!({ "visible": g.visible }))
}
@@ -1033,7 +1055,9 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
@@ -1084,13 +1108,13 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = f64_or(p, "depth", 0.0);
let dz = number_or(p, "depth", 0.0, C)?;
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, C)?;
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
@@ -1122,6 +1146,7 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
check_numbers(p, &["offset", "by"], C)?;
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
let from = old.offset(plane);
@@ -1144,9 +1169,10 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
};
// The objects on the plane where it was (not those moved off it along its normal).
let on_plane = |d: &Document| -> Vec<NodeId> {
let editable = d.editable();
persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id))
.map(|(id, ..)| id)
.collect()
};
@@ -1218,11 +1244,12 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 };
let copy = switch_or(p, "copy", false, C)?;
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
@@ -1230,10 +1257,7 @@ fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(
s,
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
)
persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy}))
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1242,11 +1266,14 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = bool_or(p, "snap", g.snap);
let snap = switch_or(p, "snap", g.snap, C)?;
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
let map = match point_param(p, "at") {
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
None => None,
let map = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => {
let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?)
}
};
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
+173 -6
View File
@@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind, RepeatKind, RepeatSpec};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_text::embed::Embedding;
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}",
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
has_doc,
info
),
@@ -35,10 +35,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Document Info's categories: (id, title), in the order the panel and the report show them.
pub const CATEGORIES: [(&str, &str); 11] = [
pub const CATEGORIES: [(&str, &str); 13] = [
("document", "Document"),
("objects", "Objects"),
("graphicStyles", "Graphic Styles"),
("brushes", "Brushes"),
("spotColors", "Spot Colors"),
("patterns", "Pattern Objects"),
("gradients", "Gradient Swatches"),
@@ -47,6 +48,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [
("fontDetails", "Font Details"),
("linkedImages", "Linked Images"),
("embeddedImages", "Embedded Images"),
("repeats", "Repeat Objects"),
];
/// The object counts of `document.info` and their labels.
@@ -96,9 +98,47 @@ pub fn embedding_label(fs_type: u16) -> &'static str {
/// An image object as Document Info lists it: (name, width, height, linked file).
type ImageRow = (String, u32, u32, Option<String>);
/// A Repeat object as Document Info lists it: its `repeatObjects` entry, and its row, which gives
/// its name and then its kind with the number of instances it draws (for a grid, its rows and
/// columns, followed by the number it draws when that is fewer).
fn repeat_entry(n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) {
let name = n.display_name();
let count = |n: usize, one: &str, many: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {many}") };
let instances = spec.instances();
let mut entry = json!({ "id": n.id.0, "name": name, "instances": instances });
let kind = &spec.kind;
let detail = match kind {
RepeatKind::Radial { .. } => {
entry["type"] = json!("radial");
count(instances, "instance", "instances")
}
RepeatKind::Mirror { .. } => {
entry["type"] = json!("mirror");
count(instances, "instance", "instances")
}
RepeatKind::Grid { rows, cols, .. } => {
// `repeat_transforms` clamps the rows and columns the same way.
let (rows, cols) = ((*rows).clamp(1, 500), (*cols).clamp(1, 500));
entry["type"] = json!("grid");
entry["rows"] = json!(rows);
entry["cols"] = json!(cols);
let grid = format!("{} × {}", count(rows as usize, "row", "rows"), count(cols as usize, "column", "columns"));
if instances < (rows * cols) as usize { format!("{grid}, {}", count(instances, "instance", "instances")) } else { grid }
}
};
(entry, (name, format!("{}, {detail}", kind.label())))
}
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects.
fn sections(
d: &Document,
info: &Value,
fonts: &BTreeSet<super::fonts::UsedFont>,
images: &[ImageRow],
repeats: &[(String, String)],
category: Option<&str>,
) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
};
@@ -138,6 +178,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
rows
}
"graphicStyles" => names("graphicStyleNames"),
"brushes" => names("brushNames"),
"spotColors" => names("spotColors"),
"patterns" => names("patternNames"),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
@@ -174,6 +215,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>
"linkedImages" => {
images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect()
}
"repeats" => repeats.to_vec(),
_ => images.iter().filter(|i| i.3.is_none()).map(|(name, w, h, _)| (name.clone(), format!("{w} × {h} px"))).collect(),
};
Section { id, title, rows }
@@ -246,6 +288,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
let mut symbols = BTreeSet::new();
let mut spot = BTreeSet::new();
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
// `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use.
let mut brushes: Vec<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() };
// `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order.
let mut repeats: Vec<Value> = vec![];
let mut repeat_rows: Vec<(String, String)> = vec![];
for r in roots {
r.walk(&mut |n| {
let k = match &n.kind {
@@ -319,6 +366,21 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
{
styles.push(&g.name);
}
if selection_only {
for item in &n.appearance.items {
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item
&& let Some(b) = &st.brush
&& !brushes.contains(b)
{
brushes.push(b.clone());
}
}
}
if let NodeKind::Repeat(spec) = &n.kind {
let (entry, row) = repeat_entry(n, spec);
repeats.push(entry);
repeat_rows.push(row);
}
});
}
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
@@ -333,6 +395,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"swatches": d.swatches_iter().count(),
"graphicStyles": d.graphic_styles.len(),
"graphicStyleNames": styles,
"brushNames": brushes,
"repeatObjects": repeats,
"characterStyles": d.char_styles.len(),
"paragraphStyles": d.para_styles.len(),
"patterns": d.patterns.len(),
@@ -340,7 +404,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
});
// The document's name: its file's, else its title.
out["document"]["name"] = json!(st.title());
let sections = sections(d, &out, &fonts, &image_rows, category);
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category);
if text {
return Ok(json!({ "text": report_text(&st.title(), selection_only, &sections) }));
}
@@ -448,6 +512,109 @@ mod tests {
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
}
/// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists
/// the brushes the selected objects' strokes use, each once.
#[test]
fn info_lists_the_brushes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let panel: Vec<Value> =
s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect();
let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap();
assert_eq!(i["brushNames"], json!(panel));
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes")));
let rows: Vec<Value> = panel.iter().map(|n| json!([n, ""])).collect();
assert_eq!(i["sections"][0]["rows"], json!(rows));
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap();
s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap();
s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap();
let names =
|s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone();
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"]));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(names(&mut s), json!(["Arrow"]));
// A stroke without a brush lists none.
let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [plain]})).unwrap();
assert_eq!(names(&mut s), json!([]));
let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}");
assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}");
}
/// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number
/// of instances it makes (for a grid, its rows and columns, followed by the number it draws when
/// that is fewer), one in a group included. With Selection Only it lists the Repeat objects in
/// the selection.
#[test]
fn info_lists_the_repeat_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let square =
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
let (a, b, c, d) = (square(&mut s, 0.0), square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0));
let radial = s.execute("object.repeat.radial", &json!({"ids": [a], "instances": 6})).unwrap()["id"].clone();
let grid = s.execute("object.repeat.grid", &json!({"ids": [b], "rows": 2, "cols": 1})).unwrap()["id"].clone();
s.execute("layer.setProps", &json!({"id": grid, "name": "Tiles"})).unwrap();
let mirror = s.execute("object.repeat.mirror", &json!({"ids": [c]})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [mirror, d]})).unwrap();
let group = s.execute("object.group", &json!({})).unwrap()["id"].clone();
let i = s.execute("document.info", &json!({"category": "repeats"})).unwrap();
assert_eq!(
i["repeatObjects"],
json!([
{"id": radial, "name": "<Radial Repeat>", "type": "radial", "instances": 6},
{"id": grid, "name": "Tiles", "type": "grid", "rows": 2, "cols": 1, "instances": 2},
{"id": mirror, "name": "<Mirror Repeat>", "type": "mirror", "instances": 2},
])
);
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("repeats"), Some("Repeat Objects")));
assert_eq!(
i["sections"][0]["rows"],
json!([
["<Radial Repeat>", "Radial Repeat, 6 instances"],
["Tiles", "Grid Repeat, 2 rows × 1 column"],
["<Mirror Repeat>", "Mirror Repeat, 2 instances"],
])
);
let names = |s: &mut Session| -> Vec<Value> {
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap();
i["repeatObjects"].as_array().unwrap().iter().map(|r| r["name"].clone()).collect()
};
s.execute("select.set", &json!({"ids": [grid, group]})).unwrap();
assert_eq!(names(&mut s), ["Tiles", "<Mirror Repeat>"]);
s.execute("select.set", &json!({"ids": [radial]})).unwrap();
assert_eq!(names(&mut s), ["<Radial Repeat>"]);
s.execute("select.set", &json!({"ids": [d]})).unwrap();
assert!(names(&mut s).is_empty());
let text = s.execute("document.info", &json!({"category": "repeats", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nREPEAT OBJECTS\n<Radial Repeat>: Radial Repeat, 6 instances\nTiles: Grid Repeat, 2 rows × 1 column\n"), "{text}");
assert!(!text.contains("\nOBJECTS\n"), "{text}");
// A radial of 1,000 copies of a 500 × 500 grid draws one copy within the instance budget,
// and Document Info reports 1 instance.
let e = square(&mut s, 350.0);
let big = s.execute("object.repeat.grid", &json!({"ids": [e], "rows": 500, "cols": 500})).unwrap()["id"].clone();
let outer = s.execute("object.repeat.radial", &json!({"ids": [big], "instances": 1000})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [outer]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap();
assert_eq!((i["repeatObjects"][0]["id"].clone(), i["repeatObjects"][0]["instances"].clone()), (outer, json!(1)));
assert_eq!((i["repeatObjects"][1]["id"].clone(), i["repeatObjects"][1]["instances"].clone()), (big, json!(250_000)));
assert_eq!(i["sections"][0]["rows"][0], json!(["<Radial Repeat>", "Radial Repeat, 1 instance"]));
// A 500 × 500 grid of a mirror draws 125,000 copies within the budget, and its row gives
// that number after its rows and columns.
let f = square(&mut s, 380.0);
let pair = s.execute("object.repeat.mirror", &json!({"ids": [f]})).unwrap()["id"].clone();
let tiles = s.execute("object.repeat.grid", &json!({"ids": [pair], "rows": 500, "cols": 500})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [tiles]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap();
assert_eq!((i["repeatObjects"][0]["id"].clone(), i["repeatObjects"][0]["instances"].clone()), (tiles, json!(125_000)));
assert_eq!(i["sections"][0]["rows"][0], json!(["<Grid Repeat>", "Grid Repeat, 500 rows × 500 columns, 125000 instances"]));
}
#[test]
fn embedding_permissions_read_from_fs_type() {
assert_eq!(embedding_label(0), "embedding allowed");
+16 -9
View File
@@ -83,13 +83,18 @@ pub fn specs() -> Vec<CommandSpec> {
"Bristle Brush Strokes",
["Select", "Object"],
None,
"{} objects whose brush name contains \"bristle\" → {count}",
"{} objects whose topmost stroke has a bristle brush (type bristle in brush.list), whatever the brush is named → {count}",
has_doc,
|s, _| by_kind(s, |n| n
.appearance
.stroke()
.and_then(|st| st.brush.as_deref())
.is_some_and(|b| b.to_ascii_lowercase().contains("bristle")))
|s, _| {
let brushes = vectorcraft_brush::library(&s.doc()?.doc);
by_kind(s, |n| {
n.appearance
.stroke()
.and_then(|st| st.brush.as_deref())
.and_then(|name| brushes.iter().find(|b| b.name == name))
.is_some_and(|b| matches!(b.kind, vectorcraft_brush::BrushKind::Bristle(_)))
})
}
),
cmd!(
"select.save",
@@ -305,7 +310,7 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool)
finish_same(s, cmd, ids)
}
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result<Value> {
let ids = candidates(s, f)?;
let n = ids.len();
s.select(|_, sel| sel.set(ids))?;
@@ -538,10 +543,11 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
let d = &s.doc()?.doc;
let mut paths = vec![];
let editable = d.editable();
for r in roots {
if let Some(n) = d.node(r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && editable(c.id) && !paths.contains(&c.id) {
paths.push(c.id);
}
});
@@ -557,7 +563,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
} else {
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
};
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
let editable = st.doc.editable();
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) }
};
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
let st = s.doc()?;
+167 -42
View File
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Anchor Point",
[],
None,
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
has_doc,
remove_anchor
),
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Point",
[],
None,
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
has_doc,
convert_anchor
),
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
has_doc,
reshape_segment
),
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Split Path",
[],
None,
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
has_doc,
split
),
@@ -152,24 +152,40 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- helpers ----------
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
/// `[[x,y]…]` or `[{x,y}…]`.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite
/// number.
fn point_entry(v: &Value, cmd: &str) -> Result<Point> {
let xy = match v {
Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)),
Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)),
_ => None,
};
xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers"))
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a
/// number nor null, is an error.
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
.map(|v| {
let pressure = match v {
Value::Array(xy) => xy.get(2),
_ => v.get("pressure"),
};
match pressure {
None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))),
Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))),
}
})
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
.collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
}
@@ -211,8 +227,18 @@ fn within_input_bound(q: Point) -> bool {
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
}
/// The whole number of 0 or more that `key` holds, read with [`index`].
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
/// value is [`usize_req`]'s error.
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => usize_req(p, key, cmd).map(Some),
}
}
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
@@ -284,7 +310,8 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
}
}
}
out.retain(|id| doc.is_editable(*id));
let editable = doc.editable();
out.retain(|id| editable(*id));
out
}
@@ -548,6 +575,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
}
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
use kurbo::ParamCurveArclen;
let (first, last) = (*pts.first()?, *pts.last()?);
let (si, _, _, _, d_a) = pd.nearest(first)?;
let (sj, _, _, _, d_b) = pd.nearest(last)?;
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
return None;
}
let mut sp = pd.subpaths.get(si)?.clone();
if sp.anchors.len() < 2 {
return None;
}
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
// new ones, which a `t` along the old one doesn't measure.
let place = |sp: &mut SubPath, q: Point| {
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
let n = sp.anchors.len();
Some(if t <= 1e-6 {
(seg % n, false)
} else if t >= 1.0 - 1e-6 {
((seg + 1) % n, false)
} else {
(sp.insert_anchor(seg, t), true)
})
};
let (mut from, _) = place(&mut sp, first)?;
let (to, added) = place(&mut sp, last)?;
if added && to <= from {
from += 1;
}
if from == to {
return None;
}
let n = sp.anchors.len();
// The length of the run of segments from anchor i forward to anchor j.
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
// `end` round to `start`, then the stroke's inner anchors follow.
let (start, end, reversed) = if sp.closed {
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
} else if from < to {
(from, to, false)
} else {
(to, from, true)
};
let mut stroke = pts.to_vec();
if reversed {
stroke.reverse();
}
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
*s0 = s1.p;
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
*e0 = e1.p;
let fit = fit_freehand(&stroke, tol, false).anchors;
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
} else {
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
};
let mut anchors = before;
if let Some(a) = anchors.last_mut() {
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
}
anchors.extend_from_slice(inner);
let at_end = anchors.len();
anchors.extend(after);
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
let end_at = if sp.closed { 0 } else { at_end };
if let Some(a) = anchors.get_mut(end_at) {
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
}
Some((si, SubPath::new(anchors, sp.closed)))
}
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let samples = samples_param(p, "points", C)?;
@@ -556,7 +664,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
let keep_selected = bool_or(p, "keepSelected", true);
@@ -630,6 +738,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
})?;
return Ok(json!({ "id": id.0 }));
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let within = number_or(rd, "within", 12.0, C)?;
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
}
let (pd, _) = path_of(&s.doc()?.doc, id)?;
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
// it to the new length if Calligraphic pressure strokes get redrawn.
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
if keep_selected {
sel.set([id]);
} else {
sel.remove(id);
}
Ok(())
})?;
return Ok(json!({ "id": id.0 }));
}
}
if pts.len() < 2 {
return Err(bad(C, "need at least two points"));
}
@@ -650,16 +780,8 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvature";
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
let pts: Vec<(Point, bool)> = arr
.iter()
.filter_map(|v| {
let pt = match v {
Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?),
_ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),
};
(pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))
})
.collect();
let pts: Vec<(Point, bool)> =
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(C, "need at least one point"));
}
@@ -692,7 +814,8 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
check_numbers(p, &["x", "y", "t"], C)?;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
@@ -756,7 +879,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -863,12 +986,14 @@ fn remove_anchors_of(
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
check_numbers(p, &["x", "y"], C)?;
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => Some(Point::new(x, y)),
_ => None,
(None, None) => None,
_ => return Err(bad(C, "x and y must be given together")),
};
if target.is_some_and(|q| !within_input_bound(q)) {
return Err(bad(C, "x and y must be finite"));
@@ -898,10 +1023,10 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
return Err(bad(C, "invalid delta"));
}
@@ -923,9 +1048,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
if anchor.is_none() && seg.is_none() {
return Err(bad(C, "need `anchor` or `segment` + `t`"));
}
@@ -1018,7 +1143,7 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseRegion";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
let size = number_or(p, "size", 10.0, C)?;
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
@@ -1060,7 +1185,7 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseSegments";
let pts = points_param(p, "points", C)?;
let w = f64_or(p, "width", 8.0);
let w = number_or(p, "width", 8.0, C)?;
if !(w.is_finite() && w > 0.0) {
return Err(bad(C, "width must be positive"));
}
@@ -1203,8 +1328,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smoothRegion";
let pts = points_param(p, "points", C)?;
let radius = f64_or(p, "radius", 12.0).max(0.0);
let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0);
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0);
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
@@ -1251,7 +1376,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.joinScrub";
let pts = points_param(p, "points", C)?;
let tol = f64_or(p, "tolerance", 12.0).max(0.0);
let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0);
let st = s.doc()?;
// Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start).
let poly = {
@@ -1350,7 +1475,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
fn blob(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.blob";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
let size = number_or(p, "size", 10.0, C)?;
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
+32 -4
View File
@@ -75,6 +75,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
"edit.clearHistory",
"Clear History",
[],
None,
"{} remove every undo and redo step of the active document (the History panel menu's Clear History): the document stays as it is, and clearing is not an undo step → {cleared: the number of steps removed}",
can_clear_history,
clear_history
),
cmd!(
"edit.undoGroup.begin",
"Begin Undo Group",
@@ -162,7 +171,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
let i = p.get("index").and_then(Value::as_u64).map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?;
if !s.close_document(i) {
return Err(bad("file.close", "no such document"));
}
@@ -170,7 +179,7 @@ fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
}
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize;
let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?;
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
}
@@ -380,7 +389,9 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.redo.last());
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
turned.named = e.named;
st.history.redo.push(turned);
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
@@ -388,6 +399,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
st.sync_new_art();
super::maskedit::follow_history(st, editing);
Ok(json!({ "undone": label }))
}
@@ -403,17 +415,33 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.undo.last());
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
turned.named = e.named;
st.history.undo.push(turned);
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
st.sync_new_art();
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
/// History panel › Clear History: removes the steps after the current state and the steps before
/// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a
/// step, as Undo keeps it, so it is removed with the others and its change stays in the document.
fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let st = s.doc_mut()?;
let cleared = st.history.undo.len() + st.history.redo.len();
st.history.undo.clear();
st.history.redo.clear();
st.revision += 1;
Ok(json!({ "cleared": cleared }))
}
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
match like {
+188 -50
View File
@@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at};
use super::expand::has_seeded;
use super::rasterfx;
use super::*;
@@ -54,6 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_params
),
cmd!(
"effect.lightingPreset",
"3D Lighting Preset",
[],
None,
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
has_doc,
lighting_preset
),
cmd!(
"effect.expandAppearance",
"Expand Appearance",
@@ -129,8 +139,11 @@ fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
Ok(())
}
const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
/// `effect.apply`: adds effect `effect` (or `id`) with `params`, its defaults filled in, to the
/// targets' active item as one undo step. A 3D effect must fit each target, which takes only one;
/// a Pathfinder effect on loose objects groups them first. → `{ids, index, item}`.
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.apply";
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
@@ -139,11 +152,12 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`params` must be an object"));
}
super::params::check_effect_color(C, id, &params)?;
super::params::check_effect_channel(C, id, &params)?;
// Crop Marks take their style from the preferences when applied.
if id == effects::CROP_MARKS && params.get("style").is_none() {
params["style"] = json!(s.crop_mark_style().id());
}
if id == GLASS {
if texture_load_effect(id) {
load_texture(&mut params, C)?;
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
@@ -161,13 +175,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if effects::is_three_d(id) {
for target in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
if id == effects::REVOLVE {
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
if effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
} else {
effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?;
effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?;
if id == effects::REVOLVE && effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
if effects::has_revolve(n) || effects::has_solid(n) {
return Err(bad(C, ONE_3D));
@@ -298,6 +308,33 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
ids.iter().filter_map(effect).collect()
}
fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.lightingPreset";
let patch = str_param(p, "preset")
.and_then(effects::lighting_preset_params)
.ok_or_else(|| bad(C, "preset must be standard, diffuse, topLeft or right"))?;
let index = index_param(p, "index", C)?;
let item = item_target(s, p, C)?;
// Validate the whole selection before changing any object. An unrelated effect or
// a missing index must not leave just some objects with a new lighting rig.
for id in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
let slot = item.effects_item(&n.appearance, C)?;
let effect = n.appearance.effects_at(slot).and_then(|fx| fx.get(index));
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
}
}
let mut options = p.clone();
let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?;
map.remove("preset");
map.insert("params".into(), patch);
set_params(s, &options)
}
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked
/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`.
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -317,8 +354,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
let edited = set_params_effects(s, p);
for effect in &edited {
super::params::check_effect_color(C, effect, &params)?;
super::params::check_effect_channel(C, effect, &params)?;
}
if edited.iter().any(|e| e == GLASS) {
if edited.iter().any(|e| texture_load_effect(e)) {
load_texture(&mut params, C)?;
}
for id in appearance_targets(s, p)? {
@@ -336,11 +374,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, &params)) {
cur.extend(new);
}
if e.id == effects::REVOLVE {
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
} else {
effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
}
effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
}
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
@@ -371,6 +405,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
/// Effect › Distort › Glass.
const GLASS: &str = "distort.glass";
/// Effect › Texture › Texturizer.
const TEXTURIZER: &str = "texture.texturizer";
/// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image
/// file (Glass, Texturizer)?
pub(crate) fn texture_load_effect(id: &str) -> bool {
id == GLASS || id == TEXTURIZER
}
/// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?,
/// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless
@@ -406,6 +448,7 @@ fn expandable(n: &Node) -> bool {
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
}
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
_ => !n.appearance.effects.is_empty(),
}
}
@@ -439,18 +482,70 @@ fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
Some((rasterfx::raster_image(d, n, below, true)?, below))
}
/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that
/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with
/// `all` every member.
fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) {
/// Expand the pieces of `m`, art evaluated from the object of the same id (its pieces share that
/// id): those made from its own fills and strokes, or with `all` every member. The other members
/// that repeat an id ([`repeats_an_id`]) and hold a random Transform effect ([`has_seeded`]) are
/// expanded as well ([`expand_in_place`]). Then the members get ids of their own. Random in a
/// Transform effect takes its seed from the id, and the canvas draws the pieces with the ids they
/// have before that. Returns what each of those other members' expansions lists, by the member's
/// new id.
fn expand_pieces(
d: &mut Document,
m: &mut Node,
all: bool,
brushes: &[vectorcraft_brush::Brush],
stroke_art: effects::StrokeArt,
) -> std::collections::HashMap<NodeId, Vec<Listed>> {
let id = m.id;
let mut seen = std::collections::HashSet::from([id]);
let mut expanded = std::collections::HashMap::new();
for c in m.children_mut().into_iter().flatten() {
let own = all || c.id == id;
let c = Arc::make_mut(c);
effects::fresh_ids(d, c, &mut seen);
if own {
let mut listed = None;
if all || c.id == id {
effects::expand_art(d, c, stroke_art);
} else if has_seeded(c) && repeats_an_id(c, &seen) {
listed = Some(expand_in_place(d, c, brushes));
}
fresh_ids_listed(d, c, &mut seen, listed.as_deref_mut().unwrap_or_default());
if let Some(listed) = listed {
expanded.insert(c.id, listed);
}
}
expanded
}
/// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids?
fn repeats_an_id(n: &Node, seen: &std::collections::HashSet<NodeId>) -> bool {
let mut ids = std::collections::HashSet::new();
let mut repeats = false;
n.walk(&mut |c| repeats |= seen.contains(&c.id) || !ids.insert(c.id));
repeats
}
/// An object whose appearance expanded: its id, and its place in the walk ([`Node::walk`]) of the
/// art it was listed in. Art evaluated from an object can hold one id more than once, and the place
/// tells which of those nodes it is.
type Listed = (NodeId, usize);
/// [`expanded_node`] of `c` in place. Returns what the expansion lists, by places in the walk of `c`.
fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec<Listed> {
let mut listed = vec![];
if let Some(e) = expanded_node(d, c.clone(), brushes, &mut listed) {
*c = e;
}
listed
}
/// [`effects::fresh_ids`] on `c`, with each of `listed` (places in the walk of `c`) given the new
/// id of the node at its place.
fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet<NodeId>, listed: &mut [Listed]) {
effects::fresh_ids(d, c, seen);
let mut ids = vec![];
c.walk(&mut |n| ids.push(n.id));
for (id, at) in listed {
if let Some(new) = ids.get(*at) {
*id = *new;
}
}
}
@@ -459,46 +554,71 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(mut n) = d.node(id).cloned() else { return };
let Some(n) = d.node(id).cloned() else { return };
let mut listed = vec![];
let m = expanded_node(d, n, brushes, &mut listed);
out.extend(listed.into_iter().map(|(id, _)| id));
if let Some(m) = m
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
}
}
/// [`expand_node`] of `n`, which need not be in `d`: `n` with its appearance expanded, `None` when
/// it has nothing to expand. `out` gets the objects whose appearance expands, by places in the walk
/// of the expanded `n`, `n`'s first when its own does.
fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<Listed>) -> Option<Node> {
let id = n.id;
if !expandable(&n) {
return;
return None;
}
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
// recoloured copy), then the rest of the appearance expands.
let mut changed = false;
// Is `n` in `out` yet?
let mut listed_n = false;
if effects::has_adjustment(&n)
&& let Some(m) = effects::adjust_in_document(d, &n)
&& let Some(slot) = d.node_mut(id)
{
*slot = m.clone();
out.push(id);
out.push((id, 0));
listed_n = true;
n = m;
changed = true;
if !expandable(&n) {
return;
return Some(n);
}
}
// (A symbol instance has become its art, a group.)
let container = is_container(&n);
// Crop Marks: a group of the object and its marks, then the object expands on its own.
// Crop Marks: the object and its marks become a group. The object expands on its own first,
// with `id`: the canvas draws it with that id, which Random in a Transform effect takes its
// seed from. Then the object and the marks get ids of their own.
if let Some(mut m) = effects::crop_marks_art(&n) {
let mut seen = std::collections::HashSet::from([id]);
for c in m.children_mut().into_iter().flatten() {
effects::fresh_ids(d, Arc::make_mut(c), &mut seen);
let mut expanded = vec![];
for (i, c) in m.children_mut().into_iter().flatten().enumerate() {
let c = Arc::make_mut(c);
if i == 0 {
expanded = expand_in_place(d, c, brushes);
fresh_ids_listed(d, c, &mut seen, &mut expanded);
} else {
effects::fresh_ids(d, c, &mut seen);
}
}
let object = m.children().and_then(|c| c.first()).map(|c| c.id);
if let Some(slot) = d.node_mut(id) {
*slot = m;
out.push(id);
if !listed_n {
out.push((id, 0));
}
if let Some(object) = object {
expand_node(d, object, brushes, out);
}
return;
out.extend(expanded.into_iter().map(|(id, at)| (id, at + 1)));
return Some(m);
}
let image = raster_image(d, &n);
let mut v = n.clone();
rasterfx::strip_raster(&mut v, false);
let mut stroke_art =
|d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st);
// Members of a group or layer that `expand_pieces` expanded, by id, with what their expansion lists.
let mut members = std::collections::HashMap::new();
let expanded = match image {
// Blur, feather and inner glow change the object itself: it becomes the image (a layer
// keeps it as its only member).
@@ -507,7 +627,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
let m = if container {
// A group's or layer's own fills and strokes become art among its members.
effects::evaluate_container(&v).map(|mut m| {
expand_pieces(d, &mut m, false, &mut stroke_art);
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
m
})
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
@@ -520,7 +640,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
_ => None,
};
effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| {
expand_pieces(d, &mut m, true, &mut stroke_art);
expand_pieces(d, &mut m, true, brushes, &mut stroke_art);
m
})
};
@@ -534,20 +654,34 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
}
}
};
if let Some(m) = expanded
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
if out.last() != Some(&id) {
out.push(id);
if let Some(m) = expanded {
n = m;
changed = true;
if !listed_n {
out.push((id, 0));
}
}
if container {
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
for c in children {
expand_node(d, c, brushes, out);
// The place of each member in the walk of `n`: after `n` and the members before it.
let mut at = 1;
for c in n.children_mut().into_iter().flatten() {
let from = out.len();
match members.remove(&c.id) {
Some(listed) => out.extend(listed),
None => {
if let Some(e) = expanded_node(d, (**c).clone(), brushes, out) {
*c = Arc::new(e);
changed = true;
}
}
}
for (_, place) in &mut out[from..] {
*place += at;
}
at += c.count();
}
}
changed.then_some(n)
}
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
@@ -572,6 +706,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
#[cfg(test)]
#[path = "tests_lighting_presets.rs"]
mod lighting_presets_tests;
#[cfg(test)]
mod tests {
use serde_json::json;
+20 -24
View File
@@ -290,7 +290,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let art = gradient_fill(item).and_then(|(f, g)| {
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
let Some(art) = art else {
@@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
let content = if g.gradient.kind == GradientKind::Freeform {
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
} else {
let frame = Frame::new(g, bounds);
@@ -363,15 +363,16 @@ fn reshaped(l: &Node) -> Option<Node> {
Some(m)
}
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
/// as a plain path, and that outline's box.
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
/// `path` as a plain path, and that outline's box.
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
if !has_geometry(&f.effects) {
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds));
}
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let b = outline.bounds()?;
Some((with_path(l, outline.clone()), outline, b))
}
@@ -432,7 +433,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
for item in &l.appearance.items {
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
let tiles = fill.and_then(|(f, def, xf)| {
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
let t = pattern_tiles(d, &def, xf, bounds);
(!t.is_empty()).then_some((f, fl, t))
});
@@ -603,7 +604,7 @@ fn seeded_here(n: &Node) -> bool {
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
/// ([`seeded_here`])?
fn has_seeded(n: &Node) -> bool {
pub(crate) fn has_seeded(n: &Node) -> bool {
seeded_here(n)
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
@@ -720,29 +721,24 @@ fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
/// colour model their stops (or freeform points) share; display RGB where models differ.
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
/// color model their stops share; display RGB where models differ. Freeform points mix in the
/// model they share ([`vectorcraft_color::Freeform::model_field`]); where their models differ, the
/// RGB color they mix to is stored in `doc_model` (the document's model) the way `paint.setFill`
/// stores an RGB color ([`super::paint::stored_color`]).
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
if g.gradient.kind == GradientKind::Freeform {
let b = bounds.abs();
let f = g.freeform_on(b);
let model = shared_model(f.points.iter().map(|p| p.color));
let field = f.field(spread_scale(b));
let field = g.freeform_on(b).model_field(spread_scale(b));
let store_in = (field.model().is_none() && doc_model != Model::Rgb).then_some(doc_model);
return Box::new(move |p| {
let ([r, g, b], a) = field.sample(p);
let c = Color::rgb(r, g, b);
(model.map_or(c, |m| c.in_model(m)), a)
let (c, a) = field.sample(p);
(super::paint::stored_color(store_in, c), a)
});
}
let geom = g.resolve(bounds);
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
}
/// The colour model all of `colors` share.
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
let m = colors.next()?.model();
colors.all(|c| c.model() == m).then_some(m)
}
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
+9 -1
View File
@@ -136,7 +136,15 @@ impl Builder {
.into_iter()
.enumerate()
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false })
.map(|(i, (name, rect))| Artboard {
id: i as u32 + 1,
name,
rect,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
})
.collect();
if self.doc.artboards.is_empty() {
return Err(err("the Affinity document has no page with a size"));
+6
View File
@@ -94,6 +94,12 @@ pub const UNSUPPORTED: &[Unsupported] = &[
extensions: &["pict", "pct", "pic"],
hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first",
},
Unsupported {
id: "pmd",
label: "PageMaker",
extensions: &["pmd"],
hint: "PageMaker (.pmd) is not supported: open the layout in PageMaker and export PDF or EPS, or place a PDF export here",
},
];
/// The unsupported format an id or extension (any case, leading dot allowed) names.
+30 -1
View File
@@ -340,7 +340,7 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen
/// `d` with one artboard, `rect`.
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }];
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, locked: false, show_center_mark: false, show_cross_hairs: false, color: None }];
d
}
@@ -411,6 +411,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
/// [`encode_all`] of `doc` as it is.
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
if let Some(plugin) = vectorcraft_plugins::export::by_format(f.id) {
return plugin_file(doc, &plugin, f, p);
}
let failed = vectorcraft_render::decode_failures();
let mut enc = encode_files(doc, f, p)?;
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
@@ -432,6 +435,32 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
Ok(enc)
}
/// The file exporter plug-in `plugin` writes of `doc` in its format `f`
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
/// one named after the document) with that path's extension when the plug-in lists it, else its
/// first. Its warnings come back with the file.
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
use vectorcraft_plugins::{Error, export};
let doc = &*doc.without_edit_modes();
let file = vectorcraft_format::save_with(doc, &vectorcraft_format::SaveOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let path = p.get("path").and_then(Value::as_str);
let listed = path.map(super::extension).and_then(|e| f.extensions.iter().copied().find(|x| *x == e));
let extension = listed.or(f.extensions.first().copied()).unwrap_or_default();
let name = super::file_stem(path.unwrap_or(&super::default_name(doc, extension)));
let params = p.get("params").cloned().unwrap_or(Value::Null);
let target = export::Target { extension, name: &name };
let out = export::run(plugin, vectorcraft_format::json_part(&file), &params, target).map_err(|e| match e {
Error::Cancelled => EngineError::Cancelled,
Error::Params(why) => bad(C, why),
e => EngineError::Other(e.to_string()),
})?;
// The plug-in's output, so reported as its other output errors are.
let invalid = || EngineError::Other(Error::Output("`dataBase64` is not valid base64".into()).to_string());
let bytes = vectorcraft_format::base64_decode(&out.data_base64).ok_or_else(invalid)?;
Ok(Encoded { warnings: out.warnings, ..Encoded::one(bytes) })
}
/// [`encode_all`] without the font substitution warning.
fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
Loaded 100 of 612 files, more files were not shown because too many files have changed in this diff. Show more