Compare commits

...
369 Commits
Author SHA1 Message Date
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
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 0d2eeed995 docs: add SonarCloud and CodeRabbit badges and standardize to flat-square 2026-10-18 11:05:48 -04:00
404 changed files with 29822 additions and 2477 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]"
Generated
+245 -274
View File
File diff suppressed because it is too large. Load diff
+9 -3
View File
@@ -15,6 +15,9 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b77
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" }
@@ -22,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" }
@@ -32,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" }
+9 -6
View File
@@ -20,11 +20,14 @@
</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">
@@ -250,7 +253,7 @@ The scores are self-assessed from our code against public documentation;
- 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);
- most of the raster effects (11 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 and Stamp 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.
+10 -4
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-19 · **Change:** minor (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)
> **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 46/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) | 81% | 8–14 |
| 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 |
@@ -123,17 +123,21 @@ 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: the 11 remaining filters (7 Artistic, 3 Sketch, 1 more) and the Effect Gallery, 8–14 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-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). |
@@ -185,6 +189,8 @@ 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 |
+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 }
+3 -2
View File
@@ -96,8 +96,9 @@ USAGE:
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
+1 -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 }
+4 -1
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 }
+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}");
}
}
+102 -23
View File
@@ -144,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);
@@ -293,7 +289,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;
}
@@ -307,7 +303,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 {
@@ -333,10 +329,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,
@@ -629,13 +624,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 {
@@ -1048,17 +1103,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.
+5 -1
View File
@@ -326,7 +326,11 @@ mod tests {
let pick = FilePick {
name: "Untitled-1.vectorcraft".into(),
folder: Some("/home/me/Art".into()),
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
filters: vec![
FilePick::filter("VectorCraft", &["vectorcraft"]),
FilePick::filter("SVG", &["svg"]),
FilePick::filter("PDF-compatible .ai", &["ai"]),
],
};
SaveRequest::new(&pick, "x11:3a0000f".into())
}
+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;
+10
View File
@@ -140,3 +140,13 @@ 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.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);
}
+29 -7
View File
@@ -413,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 {
@@ -456,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) {
@@ -517,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.
+92
View File
@@ -308,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);
@@ -557,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]
+2 -1
View File
@@ -862,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);
+21
View File
@@ -456,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);
+4 -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");
+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.
+104 -10
View File
@@ -44,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();
@@ -60,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
@@ -106,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;
@@ -131,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(),
@@ -1114,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));
+194 -18
View File
@@ -66,11 +66,16 @@ pub struct HitOptions {
/// 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, skip_guides: false, scope: None }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
}
}
@@ -134,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
}
@@ -143,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() {
@@ -267,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).
@@ -283,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);
}
@@ -363,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,
@@ -373,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 };
@@ -460,10 +522,18 @@ 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)
}
// An image is hit within its frame, and where its own fills and strokes paint
// ([`image_paint`]) except in outline mode.
NodeKind::Image(_) => {
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
// 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 {
@@ -533,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.
@@ -615,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 {
@@ -949,6 +1021,81 @@ mod tests {
}
}
/// 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]
@@ -1016,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]
@@ -1243,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:?}");
}
}
+289 -6
View File
@@ -119,8 +119,8 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
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};
@@ -438,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.
@@ -795,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")]
@@ -882,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(),
@@ -925,6 +1035,7 @@ impl Document {
assets: vec![],
svg_scripts: vec![],
puppet: None,
new_art: Default::default(),
dimensions: false,
index: Default::default(),
};
@@ -1358,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)))
@@ -1371,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] {
@@ -1446,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.
@@ -1932,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);
+39 -5
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
@@ -1026,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;
@@ -1158,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);
@@ -1210,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;
@@ -1829,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 =
@@ -1854,4 +1874,18 @@ mod tests {
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);
}
}
}
+80 -27
View File
@@ -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.
@@ -112,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,
@@ -129,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",
@@ -146,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).
@@ -253,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;
@@ -274,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;
@@ -284,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]);
@@ -300,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);
@@ -343,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();
@@ -1159,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> {
@@ -1168,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()),
@@ -1223,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)));
@@ -2168,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}");
@@ -2189,9 +2242,9 @@ 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);
@@ -2221,7 +2274,7 @@ mod tests {
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));
@@ -2246,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));
+42 -1
View File
@@ -20,7 +20,7 @@ use vectorcraft_geom::{Affine, PathData, Point};
use crate::blend::Spine;
use crate::symbols::SymbolBoxes;
use crate::{Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind};
/// The center of the nine reference points.
pub const CENTER: u8 = 4;
@@ -157,6 +157,47 @@ pub fn attachable_path(n: &Node) -> Option<&PathData> {
}
}
/// 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.
+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)]);
+43 -9
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};
@@ -48,6 +49,11 @@ pub struct Selection {
/// 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 {
@@ -148,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)) {
@@ -202,16 +212,36 @@ impl Selection {
/// 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;
}
}
@@ -225,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);
+265 -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,
}
}
}
@@ -598,6 +615,35 @@ impl Default for JustificationOptions {
}
}
/// 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
@@ -645,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 {
@@ -706,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 {
@@ -734,6 +785,7 @@ impl From<ParaStyle> for ParaStyleFile {
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = p;
Self {
@@ -756,6 +808,7 @@ impl From<ParaStyle> for ParaStyleFile {
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
@@ -782,6 +835,7 @@ impl From<ParaStyleFile> for ParaStyle {
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = f;
Self {
@@ -803,6 +857,7 @@ impl From<ParaStyleFile> for ParaStyle {
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
@@ -1187,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 }
@@ -1252,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
@@ -1267,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`.
@@ -1291,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>,
@@ -1347,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(),
@@ -1361,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(),
}
@@ -1368,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>()
@@ -1658,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]
+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");
+6 -4
View File
@@ -151,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();
+100 -3
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;
@@ -69,6 +73,7 @@ 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};
@@ -77,7 +82,7 @@ pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image
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};
@@ -143,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"]),
@@ -156,6 +162,8 @@ fn lengths_of(id: &str) -> Lengths {
"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(),
}
@@ -343,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…",
@@ -350,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…",
@@ -357,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…",
@@ -364,6 +393,20 @@ 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…",
@@ -378,6 +421,21 @@ 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…",
@@ -392,6 +450,21 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{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…",
@@ -574,6 +647,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{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…",
@@ -648,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(
@@ -731,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,
+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}");
}
}
}
+537 -6
View File
@@ -1,18 +1,78 @@
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, 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 super::edges::sobel;
use super::pixelate::{Place, cell, rand};
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::{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;
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, blur_plane};
use vectorcraft_geom::Point;
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.
@@ -161,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
@@ -241,6 +350,136 @@ 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))
@@ -314,11 +553,144 @@ pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
});
}
/// 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.
@@ -343,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]);
}
}
}
+1 -1
View File
@@ -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;
}
+144 -14
View File
@@ -1,9 +1,9 @@
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap and
//! Poster Edges, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
//! 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 › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Plaster and
//! Reticulation, Stylize ›
//! 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.
@@ -34,23 +34,31 @@ 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; 46] = [
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",
@@ -81,6 +89,8 @@ pub const PIXEL_EFFECTS: [&str; 46] = [
"sketch.plaster",
"sketch.reticulation",
"sketch.stamp",
"sketch.tornEdges",
"sketch.waterPaper",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -96,12 +106,22 @@ pub const PIXEL_EFFECTS: [&str; 46] = [
/// 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).
@@ -116,12 +136,27 @@ pub enum PixelFx {
/// 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 },
@@ -202,6 +237,14 @@ pub enum PixelFx {
/// 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
@@ -282,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),
@@ -302,6 +360,29 @@ 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),
@@ -317,6 +398,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
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),
@@ -438,6 +524,16 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
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),
@@ -475,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")),
@@ -503,9 +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::GraphicPen { 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),
@@ -522,16 +620,23 @@ impl PixelFx {
| PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. }
| PixelFx::Plaster { .. }
| PixelFx::Reticulation { .. } => 0.0,
| 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 { .. }
@@ -559,9 +664,11 @@ impl PixelFx {
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::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))
}
@@ -582,6 +689,7 @@ impl PixelFx {
// 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::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 { .. }
@@ -589,14 +697,21 @@ impl PixelFx {
| 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)),
// Cutout's palette comes from the whole object.
PixelFx::Cutout { .. } => None,
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.
@@ -617,16 +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)
@@ -659,6 +787,8 @@ impl PixelFx {
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),
@@ -669,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),
+126 -3
View File
@@ -1,13 +1,15 @@
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Note Paper, Plaster and Reticulation; Graphic Pen lives in
//! 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) and the public Photoshop Elements Artistic filters guide. Visual
//! 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 std::f64::consts::{FRAC_1_SQRT_2, TAU};
use vectorcraft_geom::Vec2;
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};
@@ -15,6 +17,8 @@ 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.
@@ -357,3 +361,122 @@ pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
*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);
});
}
+372 -1
View File
@@ -1222,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();
@@ -1426,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]
@@ -2046,6 +2071,20 @@ 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())
@@ -2144,6 +2183,35 @@ fn note_paper_embosses_a_gradient_and_grain_changes_pixels() {
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() {
@@ -2343,6 +2411,63 @@ fn reticulation_density_changes_the_grain() {
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()
@@ -2360,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})),
@@ -2377,14 +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] }
);
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.cutout", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] {
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]
@@ -2399,9 +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");
@@ -2423,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:?}"));
@@ -2431,6 +2682,43 @@ 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);
@@ -2465,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.
@@ -2478,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);
@@ -2512,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);
@@ -2549,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));
@@ -2564,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());
}
+22 -4
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),
}
}
}
@@ -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))
}
+7
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 {
+7 -5
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"appearance.addFill",
"Add New Fill",
["Window", "Appearance"],
None,
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)
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"appearance.addStroke",
"Add New Stroke",
["Window", "Appearance"],
None,
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)
@@ -218,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),
@@ -383,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> {
@@ -547,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() {
@@ -603,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)?],
+231 -32
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, 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)}}, 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
),
@@ -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,6 +1301,7 @@ 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;
@@ -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() {
+107 -75
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), 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?, 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 → {group, face}",
has_doc,
lp_fill
),
@@ -245,12 +245,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::new(d, &roots, erase, open_filled_as_closed);
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()));
@@ -311,7 +323,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 +378,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::new(d, &roots, true, bool_or(p, "openFilledAsClosed", true));
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
@@ -501,8 +513,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 +539,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 +685,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 +752,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![] };
@@ -752,6 +782,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 +791,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 +912,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.)
+11 -2
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load PDF into Clipboard",
[],
None,
"{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), 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
),
@@ -441,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)?;
@@ -510,7 +511,15 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
{
return import_loaded(s, p, C, loaded);
}
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
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() {
+4 -1
View File
@@ -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 {
+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()?;
+44 -31
View File
@@ -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,9 @@ 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
@@ -293,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
}
@@ -646,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);
@@ -722,7 +740,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
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 = f64_or(rd, "within", 12.0);
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"));
}
@@ -762,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"));
}
@@ -805,6 +815,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
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,
@@ -978,9 +989,11 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
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"));
@@ -1012,8 +1025,8 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
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"));
}
@@ -1130,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"));
}
@@ -1172,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"));
}
@@ -1315,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![];
@@ -1363,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 = {
@@ -1462,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"));
}
+4 -2
View File
@@ -171,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"));
}
@@ -179,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")) }
}
@@ -399,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 }))
}
@@ -423,6 +424,7 @@ fn redo(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!({ "redone": label }))
}
+185 -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"))?;
@@ -144,7 +157,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
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}`")))?;
@@ -162,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));
@@ -299,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)?;
@@ -320,7 +356,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
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)? {
@@ -338,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| {
@@ -373,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
@@ -442,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;
}
}
}
@@ -462,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).
@@ -510,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) {
@@ -523,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
})
};
@@ -537,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> {
@@ -575,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;
+1 -1
View File
@@ -604,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))))
+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.
+1 -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
}
+29 -2
View File
@@ -197,12 +197,38 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
)
}
/// Is `e` the error of a file whose pages are only a placeholder
/// ([`vectorcraft_pdf::PdfError::PlaceholderOnly`])?
pub(crate) fn is_placeholder(e: &EngineError) -> bool {
e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string()
}
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
let Some(range) = &opts.pages else { return true };
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
}
/// A page read from a PDF or `.ai` (`read`, as [`super::page_document`] reads it) → (document,
/// notes, false); for a `.ai` saved without PDF compatibility (its page only a placeholder), the
/// file `name` read as `document.open` reads it with `password`, from its editing copy alone →
/// (document, notes, true).
pub(crate) fn page_or_editing_data(
name: &str,
bytes: &[u8],
password: Option<&str>,
read: Result<(Document, Vec<String>)>,
) -> Result<(Document, Vec<String>, bool)> {
match read {
Ok((doc, notes)) => Ok((doc, notes, false)),
Err(e) if is_placeholder(&e) => {
let l = load_with(name, bytes, &LoadOptions { password: password.map(str::to_string), ..Default::default() })?;
Ok((l.doc, l.warnings, true))
}
Err(e) => Err(e),
}
}
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
@@ -218,7 +244,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
restore_or_import(editing, || {
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data)
.map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
Ok((r.document, r.warnings))
})?
}
@@ -256,7 +283,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
// Saved without its PDF part: the editing copy is all the file has (`pages` that
// name every page read it as the whole file).
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
Err(e) if is_placeholder(&e) => {
let whole = LoadOptions { pages: None, ..opts.clone() };
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts })
.unwrap_or(Err(e))
+68 -2
View File
@@ -59,7 +59,9 @@ pub(crate) use export::{export_crop, isolated};
pub(crate) use load::check_not_lossy_overwrite;
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
pub(crate) use load::native_file;
pub(crate) use load::page_or_editing_data;
pub(crate) use load::source;
pub use load::{
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
@@ -1153,9 +1155,11 @@ pub(crate) fn absolute_path(path: &str) -> String {
/// path that is still relative (no document) is refused, as the working directory isn't the
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
/// ([`share_path`]): a path refused as on another computer is checked again against the
/// document's path on that share. Every stored path reaches the file system through this.
/// document's path on that share. A path on a drive mapped to a network share other than the
/// document's ([`drive_share_path`]) is refused as on another computer. Every stored path
/// reaches the file system through this.
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
document_path_with(stored, document, share_path)
document_path_on(stored, document, share_path, drive_share_path)
}
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
@@ -1180,6 +1184,68 @@ pub(crate) fn document_path_with(
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
}
/// [`document_path_with`], also refusing a path on a drive that is mapped to a network share other
/// than the document's (or, for a document on this computer or without a path, to any share) as
/// [`craft_docpath::Refused::Network`]. `drive_share` gives a path's path on the share behind its
/// drive (`Y:\art\x.png` on `\\server\other\art\x.png`), or `None` for a path on this computer. A
/// path on the document's own drive is not looked up. A document written `\\?\Z:\…` is looked up
/// on its share as `Z:\…` is.
pub(crate) fn document_path_on(
stored: &str,
document: Option<&str>,
share: impl Fn(&str) -> Option<String>,
drive_share: impl FnOnce(&str) -> Option<String>,
) -> std::result::Result<String, craft_docpath::Refused> {
let share = |d: &str| share(d.strip_prefix(r"\\?\").filter(|d| drive_letter(d).is_some()).unwrap_or(d));
let path = document_path_with(stored, document, share)?;
let document = document.map(absolute_path);
let Some(drive) = drive_letter(&path) else { return Ok(path) };
if document.as_deref().and_then(drive_letter).is_some_and(|d| d.eq_ignore_ascii_case(&drive)) {
return Ok(path);
}
let Some(on_share) = drive_share(&path) else { return Ok(path) };
let home = document.map(|d| share(&d).unwrap_or(d));
craft_docpath::resolve(&on_share, home.as_deref().map(std::path::Path::new))?;
Ok(path)
}
/// The drive letter of an absolute path on a drive (`C:\…`, `C:/…` or `\\?\C:\…`).
fn drive_letter(path: &str) -> Option<char> {
let mut chars = path.strip_prefix(r"\\?\").unwrap_or(path).chars();
match (chars.next(), chars.next(), chars.next()) {
(Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic() => Some(l),
_ => None,
}
}
/// The path of `path` (on a drive, `Y:\art\x.png`) on the network share the drive is mapped to
/// (`\\server\other\art\x.png`), when the drive's root folder is on one and automation may read
/// `path`. The root folder's final path ([`std::fs::canonicalize`]) names the share.
#[cfg(windows)]
pub(crate) fn drive_share_path(path: &str) -> Option<String> {
may_read(path).ok()?;
drive_share_of(path, |root| std::fs::canonicalize(root).ok())
}
#[cfg(not(windows))]
pub(crate) fn drive_share_path(_: &str) -> Option<String> {
None
}
/// The path of `path` on the network share its drive is mapped to, given `final_path`, which
/// returns the final path of the drive's root folder (`Y:\`): as [`share_of`] gives it for a
/// document, also for a path written `\\?\Y:\…`.
#[cfg(any(windows, test))]
pub(crate) fn drive_share_of(path: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
let path = path.strip_prefix(r"\\?\").unwrap_or(path);
share_of(path, |folder| {
let (root, rest) = (folder.get(..3)?, folder.get(3..)?);
let root = final_path(root)?;
let root = root.to_str()?.trim_end_matches('\\');
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
})
}
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
@@ -346,6 +346,77 @@ fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
assert!(e.contains("placeholder"), "{e}");
}
/// [`PLACEHOLDER`]'s `.ai` files as `dataBase64`: the sample's editing data, damaged editing data
/// and none.
fn placeholder_files() -> [String; 3] {
[compressed(&ai::sample_data()), b"%AI12_CompressedData not zlib".to_vec(), vec![]]
.map(|private| vectorcraft_format::base64_encode(&ai::ai(&private, PLACEHOLDER)))
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() {
let [sample, damaged, plain] = placeholder_files();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// clipboard.importPdf (the PDF flavor's command) takes every object of both layers, as
// document.open reads them.
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": sample})).unwrap();
assert_eq!(r["count"], json!(7), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
let art = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
assert_eq!(names(&art), ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
assert_eq!(art.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": damaged})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": plain})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
let [sample, damaged, plain] = placeholder_files();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// The place takes the art of the layers that show on the artboard and clips it to the artboard.
let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": sample, "link": false})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}");
let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone();
let kids = placed.children().unwrap();
assert_eq!(names(&kids[1..]), ["Pair", "Ring", "Window"], "after the clipping path");
// With `crop: "bounding"` the size is the bounds of the art that shows. They run from the red
// square's left edge to the window's right one; the pair's hidden square, stroked 2 pt wide,
// is left out.
let r = s.execute("file.place.info", &json!({"name": "art.ai", "dataBase64": sample, "crop": "bounding"})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(170.0), Some(40.0)), "{r}");
// The place takes the art of both layers that show.
let square = |x: f64| format!("0 g\n{}f\n", ai::rect(x, 10.0, x + 40.0, 50.0));
let two = ai::editing_data(200.0, 100.0, &[ai::layer("Back", &square(10.0)), ai::layer("Front", &square(110.0))].concat());
let two = vectorcraft_format::base64_encode(&ai::ai(&compressed(two.as_bytes()), PLACEHOLDER));
let r = s.execute("file.place.info", &json!({"name": "two.ai", "dataBase64": two, "crop": "bounding"})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(140.0), Some(40.0)), "{r}");
// The file has two 100-point artboards and one placeholder page. The second artboard lies 50
// below the first. A square of 40 lies on the first and an oblong of 60 × 40 on the second.
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let art = format!("0 g\n{}f\n{}f\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(10.0, -140.0, 70.0, -100.0));
let paged = ai::editing_data(100.0, 100.0, &(boards.to_string() + &ai::layer("Art", &art)));
let paged = vectorcraft_format::base64_encode(&ai::ai(&compressed(paged.as_bytes()), PLACEHOLDER));
let mut size = |page: u64| {
let r = s.execute("file.place.info", &json!({"name": "boards.ai", "dataBase64": paged, "page": page, "crop": "bounding"}));
r.map(|r| (r["width"].as_f64(), r["height"].as_f64())).map_err(|e| e.to_string())
};
assert_eq!(size(1), Ok((Some(40.0), Some(40.0))));
assert_eq!(size(2), Ok((Some(60.0), Some(40.0))));
let third = size(3);
assert!(third.as_ref().is_err_and(|e| e.contains("has 2 page(s)")), "{third:?}");
let e = s.execute("file.place", &json!({"name": "damaged.ai", "dataBase64": damaged})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
let e = s.execute("file.place", &json!({"name": "plain.ai", "dataBase64": plain})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
+6 -1
View File
@@ -169,7 +169,7 @@ fn formats_list_dxf_and_what_can_not_be_written() {
}
assert!(r["writable"].as_array().unwrap().contains(&json!("dxf")));
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect();
assert_eq!(unsupported, ["dwg", "pict"]);
assert_eq!(unsupported, ["dwg", "pict", "pmd"]);
}
#[test]
@@ -191,4 +191,9 @@ fn dwg_and_pict_say_what_to_use_instead() {
.unwrap_err()
.to_string();
assert!(e.contains("DWG is not available"), "{e}");
let e = s
.execute("document.open", &json!({"name": "brochure.PMD", "dataBase64": vectorcraft_format::base64_encode(b"PageMaker")}))
.unwrap_err()
.to_string();
assert!(e.contains("brochure.PMD") && e.contains("PageMaker"), "{e}");
}
+2 -1
View File
@@ -473,7 +473,8 @@ fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let clipping = |id: NodeId| {
d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id))
};
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect())
let editable = d.editable();
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && editable(*id) && !clipping(*id)).collect())
}
// ---------- preview ----------
+35 -10
View File
@@ -301,7 +301,8 @@ fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
}
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
/// families that aren't available: their type is drawn in the fallback font, not the font it names.
/// families that aren't available (their type is drawn in the fallback font, not the font it
/// names) or styles that its family doesn't have (the closest style is drawn).
pub(super) fn substitution_warning(d: &Document) -> Option<String> {
substitution_warning_of(used_fonts(d))
}
@@ -309,20 +310,34 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
/// [`substitution_warning`] for type set in `fonts`.
pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let mut missing: Vec<String> = fonts
.into_iter()
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _, _)| family)
.collect();
let mut missing: Vec<String> = Vec::new();
let mut styles: BTreeSet<String> = BTreeSet::new();
for (family, style, _) in fonts {
match db.resolve(&family, &style) {
None | Some((_, vectorcraft_text::FontMatch::Missing)) => missing.push(family),
Some((face, vectorcraft_text::FontMatch::Style)) => {
styles.insert(format!("{family} {style} was drawn in {} {}", face.family, face.style));
}
Some(_) => {}
}
}
// Sorted by family: its styles and versions are neighbours.
missing.dedup();
(!missing.is_empty()).then(|| {
format!(
let mut parts = Vec::new();
if !missing.is_empty() {
parts.push(format!(
"fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)",
vectorcraft_text::FALLBACK_FAMILY,
missing.join(", ")
)
})
));
}
if !styles.is_empty() {
parts.push(format!(
"font styles that aren't available were written in the closest style: {} (pick a style the family has)",
styles.into_iter().collect::<Vec<_>>().join(", ")
));
}
(!parts.is_empty()).then(|| parts.join("; "))
}
fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> {
@@ -585,6 +600,16 @@ mod open_tests {
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
}
#[test]
fn export_warns_when_a_style_is_drawn_in_another_style() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
let out = s.execute("document.export", &json!({"format": "pdf"})).unwrap();
let warnings = out["warnings"].to_string();
assert!(warnings.contains("Source Sans 3 Black Wide was drawn in Source Sans 3"), "{out}");
}
#[test]
fn opened_documents_have_exact_text_bounds() {
let mut s = Session::new();
+4 -2
View File
@@ -227,7 +227,7 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
/// The `index` param, else the selected point.
fn point_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
match p.get("index") {
Some(v) => v.as_u64().map(|i| i as usize).ok_or_else(|| bad(cmd, "`index` must be a point index")),
Some(v) => v.as_u64().map(index).ok_or_else(|| bad(cmd, "`index` must be a point index")),
None => s.selected_freeform_point().ok_or_else(|| bad(cmd, "no point selected (give `index`)")),
}
}
@@ -244,6 +244,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
super::paint::check_keep_model(p, C)?;
let given = given_color(s, p);
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
@@ -266,6 +267,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
super::paint::check_keep_model(p, C)?;
let given = given_color(s, p);
edit(s, p, C, |f, sp| {
check_index(f, i)?;
@@ -318,7 +320,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
None => return Err(bad(C, "missing `index` (a point index, or null to clear)")),
Some(Value::Null) => None,
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`index` must be a whole number or null"))?;
let (f, _) = s.proxy_freeform(!s.fill_active).ok_or_else(|| bad(C, NOT_FREEFORM))?;
check_index(&f, i).map_err(|e| bad(C, e))?;
Some(i)
+2 -1
View File
@@ -60,7 +60,7 @@ fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")),
Some(Value::Null) => None,
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`index` must be a whole number or null"))?;
let n = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?.gradient.stops.len();
if i >= n {
return Err(bad(C, format!("no stop {i} (the gradient has {n})")));
@@ -381,6 +381,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
};
super::paint::check_keep_model(p, C)?;
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, p);
// Stops naming swatches take their colours from them.
+540 -22
View File
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Column…",
["Object", "Graph"],
None,
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); type in the design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits, the first digit giving the least number of places before the decimal point (zeros added in front; 0 = as many as the value needs) and the second digit giving the places after it (rounded half away from zero), at the type's own size where the design's copy puts it (a repeating design shows it once, where a whole copy ending at the value end puts it; type inside a group that clips, is hidden or has its own opacity, blend mode, isolation, knockout, appearance or opacity mask, or inside a blend, compound shape, envelope, repeat or intertwine, stays there and is filled in each copy, drawn with the design; type inside a symbol stays as typed), and is left out of the legend swatch (a design of such type alone gets the default swatch); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
has_selection,
set_column
),
@@ -373,6 +373,7 @@ impl Gen<'_> {
let style = label_style(self.styles, role);
let mut t = TextObject::point(at, s, style);
t.para.justify = justify;
t.system_layout = self.d.setup.system_layout;
refresh_bounds(&mut t);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
@@ -413,10 +414,11 @@ impl Gen<'_> {
None => self.path(shapes::rectangle(cell), fill, stroke, w),
}
}
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
/// A legend swatch filling `cell`: the series' column design with its totals left out, else, and when the design
/// draws nothing else, its [`Self::marker`].
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
let fitted = match column {
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
let fitted = match column.and_then(|c| Some((c, c.legend.as_ref()?))) {
Some((c, legend)) if self.columns.take(legend.count()) => c.fit(self.d, legend, cell),
_ => None,
};
match fitted {
@@ -552,12 +554,164 @@ fn fit_affine(frame: Rect, cell: Rect) -> Affine {
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
const COLUMN_BUDGET: usize = 200_000;
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
/// those guides.
/// A series' column design (Object › Graph › Column): its options; its art without guide lines and without the totals
/// taken out of it ([`without_totals`]); the bounds of those guides; those totals; its frame (the art's without guides,
/// totals included, [`design_frame`]); whether its art still holds totals (inside groups that do more than group, or
/// inside other objects that hold objects), which are filled where they are; and its art without any total for the
/// legend swatch, `None` when that draws nothing.
struct ColumnArt {
options: ColumnDesign,
art: Node,
guides: Vec<Rect>,
totals: Vec<Node>,
frame: Option<Rect>,
in_place: bool,
legend: Option<Node>,
}
impl ColumnArt {
/// A copy of `art` with the design's frame fitted to `cell`, as [`fit_design`] fits it. Fresh ids.
fn fit(&self, d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
let art = super::place::transformed(art.clone(), fit_affine(self.frame?, cell));
Some(Arc::new(d.reid(&art)))
}
}
/// The column design totals in `s` (Add totals to a column design): each `%` followed by two digits, as its byte offset,
/// the places before the decimal point (the first digit) and the places after it (the second), left to right. A `%` not
/// followed by two digits is plain text.
fn total_spans(s: &str) -> Vec<(usize, usize, usize)> {
let b = s.as_bytes();
let mut out = vec![];
let mut i = 0;
while let Some(w) = b.get(i..i + 3) {
match *w {
[b'%', x, y] if x.is_ascii_digit() && y.is_ascii_digit() => {
out.push((i, usize::from(x - b'0'), usize::from(y - b'0')));
i += 3;
}
_ => i += 1,
}
}
out
}
/// Value `v` as a column design total shows it: rounded half away from zero, from its shortest decimal form, to `after`
/// places after the decimal point, zeros added as needed; and with at least `before` places before the point, zeros
/// added in front (0: as many as the value needs). The places before the point are never cut, and a value that rounds
/// to 0 has no minus sign.
fn total_text(v: f64, before: usize, after: usize) -> String {
if !v.is_finite() {
return fmt_value(v);
}
let s = format!("{}", v.abs());
let (int, frac) = s.split_once('.').unwrap_or((s.as_str(), ""));
// The digits kept, the point `after` places from the end.
let mut digits: Vec<u8> = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(after)).collect();
if frac.as_bytes().get(after).is_some_and(|d| *d >= b'5') {
let mut carry = true;
for d in digits.iter_mut().rev() {
if *d == b'9' {
*d = b'0';
} else {
*d += 1;
carry = false;
break;
}
}
if carry {
digits.insert(0, b'1');
}
}
let (whole, places) = digits.split_at(digits.len().saturating_sub(after));
let mut out = String::new();
if v < 0.0 && digits.iter().any(|d| *d != b'0') {
out.push('-');
}
out.extend(std::iter::repeat_n('0', before.saturating_sub(whole.len())));
out.extend(whole.iter().map(|d| char::from(*d)));
if after > 0 {
out.push('.');
out.extend(places.iter().map(|d| char::from(*d)));
}
out
}
/// Type `t` with each total in it ([`total_spans`]) replaced by value `v` ([`total_text`]), styled as its `%` was; true
/// when it held one.
fn fill_totals(t: &mut TextObject, v: f64) -> bool {
let spans = total_spans(&t.plain_text());
// Last first, so the offsets of earlier ones stay valid.
for &(at, before, after) in spans.iter().rev() {
let s = total_text(v, before, after);
t.splice_paras(at, at + 3, &s);
vectorcraft_text::edit::replace_range(&mut t.runs, at, at + 3, &s);
}
if !spans.is_empty() {
refresh_bounds(t);
}
!spans.is_empty()
}
/// Is `n` type that holds a total ([`total_spans`])?
fn holds_total(n: &Node) -> bool {
matches!(&n.kind, NodeKind::Text(t) if !total_spans(&t.plain_text()).is_empty())
}
/// Does group `n` only group its children: no clipping, hiding, opacity, blend mode, isolation, knockout, appearance of
/// its own or opacity mask, so that type taken out of it draws as it did inside it?
fn only_groups(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. })
&& n.visible
&& n.opacity == 1.0
&& n.blend == vectorcraft_color::BlendMode::Normal
&& !n.isolate
&& n.knockout == vectorcraft_doc::Knockout::default()
&& n.appearance.items.is_empty()
&& n.appearance.effects.is_empty()
&& n.extras().mask.is_none()
}
/// Is `n` a clipping group whose clipping path is type that holds a total?
fn clipped_by_total(n: &Node) -> bool {
matches!(&n.kind, NodeKind::Group { clip: true, children } if children.first().is_some_and(|c| holds_total(c)))
}
/// `art` without the type in it that holds a total, inside groups at any depth that only group ([`only_groups`]), and
/// that type, pushed to `totals` in paint order (Add totals to a column design); with `all`, without every total inside
/// any group or other object that holds objects (a blend, compound shape, envelope, repeat or intertwine), and with
/// each group that such type clips left empty. A design that is such type alone leaves an empty group.
fn without_totals(art: &Node, all: bool, totals: &mut Vec<Node>) -> Node {
if holds_total(art) || (all && clipped_by_total(art)) {
totals.push(art.clone());
return Node::group(art.id, vec![]);
}
let mut n = art.clone();
let take = all || only_groups(&n);
if take && let Some(children) = n.children_mut() {
for c in std::mem::take(children) {
if holds_total(&c) {
totals.push((*c).clone());
} else if c.children().is_some() {
children.push(Arc::new(without_totals(&c, all, totals)));
} else {
children.push(c);
}
}
}
n
}
/// `n` with the totals in it at any depth, in groups and the other objects that hold objects, showing value `v` where
/// they are ([`fill_totals`]).
fn fill_in_place(n: &mut Node, v: f64) {
if let NodeKind::Text(t) = &mut n.kind {
fill_totals(t, v);
} else if let Some(children) = n.children_mut() {
for c in children.iter_mut() {
fill_in_place(Arc::make_mut(c), v);
}
}
}
/// `art` without the paths made into guides, at any depth.
@@ -585,7 +739,16 @@ fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
guides.push(b);
}
});
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
let art = without_guides(art);
let frame = design_frame(&art);
let legend = without_totals(&art, true, &mut vec![]);
let mut draws = false;
legend.walk(&mut |n| draws |= n.children().is_none());
let mut totals = vec![];
let art = without_totals(&art, false, &mut totals);
let mut in_place = false;
art.walk(&mut |n| in_place |= holds_total(n));
Some(ColumnArt { options: options.clone(), art, guides, totals, frame, in_place, legend: draws.then_some(legend) })
})
.collect()
}
@@ -621,30 +784,42 @@ impl Allowance {
}
}
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
/// Column or bar `rect` of value `value` drawn with column design `c` (Object › Graph › Column), as a group of fresh art,
/// its copies spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a
/// column above zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot
/// points per unit of value. The design's totals show `value` and come last: each keeps its size and is moved to where
/// the design's copy puts the point it is set from; in a repeating design they are drawn once, where a whole copy ending
/// at the value end puts them, and a sliding design moves each with the part of the design on its side of the guide.
/// Totals left in the art ([`ColumnArt`]) show `value` in each copy, drawn with it. `None` for art with no area, for a
/// column of no length and when `left` runs out, which are drawn plain.
fn column_art(
d: &mut Document,
c: &ColumnArt,
rect: Rect,
(rect, value): (Rect, f64),
(horizontal, far_low): (bool, bool),
per_unit: f64,
left: &mut Allowance,
) -> Option<Arc<Node>> {
let frame = design_frame(&c.art)?;
let frame = c.frame?;
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
if length(rect) <= 1e-9 || !length(rect).is_finite() {
return None;
}
let cost = c.art.count();
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
// A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved
// by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when
// `keep_low` (two objects more).
let copy = |d: &mut Document, a: Affine| {
let mut art = d.reid(&super::place::transformed(c.art.clone(), a));
if c.in_place {
fill_in_place(&mut art, value);
}
art
};
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| left.take(cost).then(|| Arc::new(copy(d, fit_affine(frame, cell))));
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
let art = left.take(cost + 2).then(|| copy(d, a))?;
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
// stroke counts only in its own object's box.
let mut b = vectorcraft_render::painted_bounds(d, &art);
@@ -653,7 +828,8 @@ fn column_art(
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
}
});
let b = b.or_else(|| art.geometric_bounds())?;
// A copy that draws nothing (a design of totals alone) needs no clip.
let Some(b) = b.or_else(|| art.geometric_bounds()) else { return Some(Arc::new(art)) };
let keep = match (along_x, keep_low) {
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
@@ -669,7 +845,35 @@ fn column_art(
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
};
// The column's group: `parts`, then the totals, each showing `value` at its own size, moved as `a` moves the point it
// is set from; with `sliding` (the kept part's map, the stretched part's map and the guide, as [`slide`] gives them),
// as the map of the part on its side of the guide moves it. One object each.
let group = |d: &mut Document, mut parts: Vec<Arc<Node>>, a: Affine, sliding: Option<(Affine, Affine, f64)>, left: &mut Allowance| {
for t in &c.totals {
if !left.take(1) {
return None;
}
let mut n = t.clone();
let mut by = Affine::IDENTITY;
if let NodeKind::Text(text) = &mut n.kind {
// A move only, so the type keeps its size; pattern paints move with it.
let p = text.xf * Point::ZERO;
let a = match sliding {
Some((kept, stretched, at)) => {
let q = kept * p;
if (horizontal && q.x >= at) || (!horizontal && q.y <= at) { kept } else { stretched }
}
None => a,
};
fill_totals(text, value);
by = Affine::translate(a * p - p);
}
parts.push(Arc::new(d.reid(&super::place::transformed(n, by))));
}
Some(Arc::new(Node::group(d.alloc_id(), parts)))
};
let mut parts = vec![];
let (mut label, mut sliding) = (fit_affine(frame, rect), None);
match c.options.column_type {
ColumnType::Uniform => {
// Scaled both ways to the column's length, centered across it.
@@ -680,14 +884,17 @@ fn column_art(
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
};
parts.push(fit(d, cell, left)?);
label = fit_affine(frame, cell);
}
ColumnType::Repeating => {
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
// is drawn vertically scaled.
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
parts.push(fit(d, rect, left)?);
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
return group(d, parts, label, None, left);
};
// The totals go where a whole copy ending at the value end puts them.
label = fit_affine(frame, piece(length(rect) - unit, length(rect)));
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
let whole = ((length(rect) + 1e-6) / unit).floor();
let rest = length(rect) - whole * unit;
@@ -717,12 +924,13 @@ fn column_art(
// Each copy cut at the guide, on its own side.
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
sliding = Some((kept, stretched, at));
}
None => parts.push(fit(d, rect, left)?),
},
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
}
Some(Arc::new(Node::group(d.alloc_id(), parts)))
group(d, parts, label, sliding, left)
}
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
@@ -1240,7 +1448,8 @@ fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnA
let rect = bar_rect(a, bar, v0, v1);
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
// A column drawn with a design casts no shadow.
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns))
{
Some(n) => {
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
if let Some(art) = n.geometric_bounds() {
@@ -1281,7 +1490,7 @@ fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnA
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
let rect = bar_rect(a, w, v0, v1);
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
Some(n) => {
if let Some(art) = n.geometric_bounds() {
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
@@ -4100,6 +4309,315 @@ mod tests {
}
}
/// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a
/// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5
/// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from.
fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] {
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap());
let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap());
let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap());
let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap());
let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO,
k => panic!("type: {k:?}"),
};
let points = [origin(s, above), origin(s, below)];
s.execute("select.set", &json!({"ids": [votes, below]})).unwrap();
let inner = id_of(s.execute("object.group", &json!({})).unwrap());
let mut ids = vec![frame, above, inner];
if guide {
let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap());
s.execute("select.set", &json!({"ids": [line]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
ids.push(line);
}
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": name})).unwrap();
points
}
/// The text of each piece of type inside `n`, in paint order.
fn texts_in(n: &vectorcraft_doc::Node) -> Vec<String> {
let mut out = vec![];
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
out.push(t.plain_text());
}
});
out
}
/// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size.
fn totals_of(nodes: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Vec<(String, vectorcraft_geom::Point)> {
nodes
.iter()
.map(|n| match &n.kind {
NodeKind::Text(t) => {
let [a, b, c, d, x, y] = t.xf.as_coeffs();
assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf);
(t.plain_text(), vectorcraft_geom::Point::new(x, y))
}
k => panic!("a total: {k:?}"),
})
.collect()
}
fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool {
(a - b).hypot() < 1e-6
}
#[test]
fn column_design_totals_show_each_columns_value_at_their_own_size() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let [above, below] = save_totals_design(&mut s, "Ballot", false);
let id = one_series(&mut s, "column", &[35.0, 7.256]);
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap();
let check = |s: &Session, want: [[&str; 2]; 2]| {
let parts = series_parts(s, id, 0);
for (column, want) in parts.iter().zip(want) {
let children = column.children().unwrap();
// The design's copy keeps the type without a total, stretched with the design, and leaves the totals out.
assert_eq!(texts_in(&children[0]), ["Votes"]);
let frame = frames(column)[0];
let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0);
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy);
let totals = totals_of(&children[1..]);
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), want);
// Each set from where the design's copy puts the point it was set from.
assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}");
}
// The legend swatch keeps the type without a total and leaves the totals out.
assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]);
};
check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]);
// New data draws the new values.
s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap();
check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]);
// The design in the Graph Design list is left as it was.
assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]);
// A bar below zero shows its minus sign, its totals moved with the design along the bar.
let bars = one_series(&mut s, "bar", &[-15.0]);
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap();
let bar = series_parts(&s, bars, 0)[0].clone();
let frame = frames(&bar)[0];
let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0);
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy);
let totals = totals_of(&bar.children().unwrap()[1..]);
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["-015.0", "-15.00 and -15"]);
assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}");
// A design that is a single piece of type with a total shows the value alone, and its legend swatch is the
// default square.
let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [one]})).unwrap();
s.execute("graph.design", &json!({"new": "Count"})).unwrap();
let count = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap();
let parts = series_parts(&s, count, 0);
assert_eq!(texts_in(&parts[0]), ["35"]);
assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1);
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
// Repeating, with a last copy to chop, it still shows the value.
s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap();
let column = series_parts(&s, count, 0)[0].clone();
assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind);
}
#[test]
fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let [above, below] = save_totals_design(&mut s, "Ballot", true);
let id = one_series(&mut s, "column", &[35.0]);
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap();
let column = series_parts(&s, id, 0)[0].clone();
let children = column.children().unwrap();
// Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut.
assert_eq!(children.len(), 6, "{children:?}");
assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped");
assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"]));
// Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column.
let sx = plain.width() / 10.0;
let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0);
let totals = totals_of(&children[4..]);
assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["035.0", "35.00 and 35"]);
assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}");
// Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the
// design with the part stretched down to the zero line.
s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap();
let column = series_parts(&s, id, 0)[0].clone();
let children = column.children().unwrap();
assert_eq!(children.len(), 4, "two halves and two totals: {children:?}");
let k = (140.0 - 5.0 * sx) / 5.0;
let totals = totals_of(&children[2..]);
let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx);
let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k);
assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}");
// A total's pattern fill moves with it.
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
let origin = vectorcraft_geom::Point::new(0.0, -2.0);
let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default());
let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY };
total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into();
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) });
let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() };
let g = GraphSpec {
rows: vec![vec![35.0]],
axis_min: Some(0.0),
axis_max: Some(50.0),
series_columns: vec![Some(options)],
..GraphSpec::default()
};
let out = super::generate(&mut d, &g);
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0);
assert_eq!(text, "35");
let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") };
assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}");
}
#[test]
fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap());
let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap());
s.execute("select.set", &json!({"ids": [total]})).unwrap();
let faded = id_of(s.execute("object.group", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [faded]})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap();
s.execute("graph.design", &json!({"new": "Faded"})).unwrap();
let id = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap();
// The total stays in its half-opaque group: each of the four copies shows the value there.
let parts = series_parts(&s, id, 0);
let copies = parts[0].children().unwrap();
assert_eq!(copies.len(), 4, "{copies:?}");
let mut faded_groups = 0;
for c in copies {
assert_eq!(texts_in(c), ["35"]);
c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6));
}
assert_eq!(faded_groups, 4);
// The legend swatch shows the design without it.
let swatch = parts.last().unwrap();
assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind);
// Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out
// what it clips.
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap());
let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap());
let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap());
s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap();
let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap();
s.execute("graph.design", &json!({"new": "Masked"})).unwrap();
let masked = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap();
let parts = series_parts(&s, masked, 0);
let clip_groups = |n: &vectorcraft_doc::Node| {
let mut out = vec![];
n.walk(&mut |c| {
if let NodeKind::Group { clip: true, children } = &c.kind {
out.push(children.first().map(|m| texts_in(m)).unwrap_or_default());
}
});
out
};
assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value");
let swatch = parts.last().unwrap();
let mut drawn = 0;
swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. })));
assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}");
// Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves
// them out.
let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap());
let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap());
let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap());
s.execute("select.set", &json!({"ids": [first, second]})).unwrap();
let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap();
s.execute("graph.design", &json!({"new": "Blended"})).unwrap();
let blended = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap();
let parts = series_parts(&s, blended, 0);
let mut blends = vec![];
parts[0].walk(&mut |n| {
if matches!(n.kind, NodeKind::Blend { .. }) {
blends.push(texts_in(n));
}
});
assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]);
assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap());
// A design that is that blend alone gets the default legend swatch.
s.execute("select.set", &json!({"ids": [blend]})).unwrap();
s.execute("graph.design", &json!({"new": "Keys"})).unwrap();
let keys = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap();
let parts = series_parts(&s, keys, 0);
assert_eq!(texts_in(&parts[0]), ["35", "35"]);
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
// A design of a total inside two plain groups shows the value, and its legend swatch is the default square.
let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap());
s.execute("select.set", &json!({"ids": [total]})).unwrap();
let inner = id_of(s.execute("object.group", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [inner]})).unwrap();
let outer = id_of(s.execute("object.group", &json!({})).unwrap());
s.execute("select.set", &json!({"ids": [outer]})).unwrap();
s.execute("graph.design", &json!({"new": "Nested"})).unwrap();
let nested = one_series(&mut s, "column", &[35.0]);
s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap();
let parts = series_parts(&s, nested, 0);
assert_eq!(texts_in(&parts[0]), ["35"]);
assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind);
}
#[test]
fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() {
for (v, before, after, want) in [
(122.0, 3, 0, "122"),
(122.0, 0, 0, "122"),
(5.0, 3, 1, "005.0"),
(1234.0, 2, 0, "1234"),
(2.5, 0, 0, "3"),
(-2.5, 0, 0, "-3"),
(0.25, 0, 0, "0"),
(0.25, 0, 2, "0.25"),
(1.005, 0, 2, "1.01"),
(0.1 + 0.2, 0, 2, "0.30"),
(9.995, 0, 2, "10.00"),
(99.96, 0, 1, "100.0"),
(-0.004, 0, 2, "0.00"),
(-7.25, 2, 1, "-07.3"),
(7.0, 0, 9, "7.000000000"),
(1e21, 0, 0, "1000000000000000000000"),
] {
assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}");
}
// Each `%` and two digits, left to right; any other `%` stays.
let style = CharStyle::default;
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style());
assert!(super::fill_totals(&mut t, 7.25));
assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%");
// A total split between two runs takes the style of its `%`.
let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() });
t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)];
assert!(super::fill_totals(&mut t, 7.25));
assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::<Vec<_>>(), [("a 007.3", 10.0), (" b", 20.0)]);
// Type without a total is left as it is.
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style());
assert!(!super::fill_totals(&mut t, 7.25));
assert_eq!(t.plain_text(), "Votes: 50%");
}
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
NodeId(
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
+2
View File
@@ -89,6 +89,8 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let style = edit::insertion_style(&t.runs, a, b);
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
// The inline character is one plain-text byte range: ruby over it follows the text.
t.ruby_after_replace(a, b, vectorcraft_doc::text::INLINE_CHAR.len_utf8());
refresh_bounds(t);
Ok(caret)
})?;
+53 -16
View File
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Artboard",
["Window", "Artboards"],
None,
"{x?, y?, width?, height?, name?} (default: right of the last)",
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none)} (default place: right of the last)",
has_doc,
artboard_new
),
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Artboard",
["Window", "Artboards"],
None,
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left, and a locked one is an error",
has_doc,
artboard_delete
),
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
"{index | artboards: [{index, name?, color?, locked?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Artwork Bounds",
["Object", "Artboards"],
None,
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art",
has_doc,
artboard_fit_art
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Selected Art",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes); a locked artboard stays as it is (an error when the fit would change it)",
has_selection,
artboard_fit_sel
),
@@ -289,7 +289,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
match v {
Value::Number(n) => match n.as_u64() {
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
Some(i) if index(i) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
_ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)),
},
Value::String(s) => {
@@ -781,7 +781,7 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "node.move";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let parent = id_param(p, "parent");
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize;
let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index);
let d = &s.doc()?.doc;
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
if !accepts(d, parent, n) {
@@ -803,16 +803,27 @@ fn rect_from(p: &Value, default: Rect) -> Rect {
Rect::new(x, y, x + w, y + h)
}
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
/// null (no color), else `Some(Some(color))`.
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
match p.get("color") {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(v) => color_value(v).map(|c| Some(Some(c))).ok_or_else(|| bad(cmd, format!("bad color {v}"))),
}
}
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = &s.doc()?.doc;
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
let r = rect_from(p, def);
let color = artboard_color(p, "artboard.new")?.flatten();
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
d.artboards.push(Artboard { id, name, rect: r, locked: false, show_center_mark: false, show_cross_hairs: false, color });
Ok(d.artboards.len() - 1)
})?;
Ok(json!({ "index": idx }))
@@ -820,6 +831,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
s.edit("Delete Artboard", |d, sel| {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
@@ -834,13 +846,15 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// One artboard's part of `artboard.setProps`: its index, new rectangle and name, how far it moved,
/// whether it was resized, the map its art and guides take when they go with it (`fit` when it
/// scales them), and that art.
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color and lock, how
/// far it moved, whether it was resized, the map its art and guides take when they go with it (`fit` when
/// it scales them), and that art.
struct ArtboardEdit<'a> {
index: usize,
now: Rect,
name: Option<&'a str>,
color: Option<Option<vectorcraft_color::Color>>,
lock: Option<bool>,
delta: vectorcraft_geom::Vec2,
resized: bool,
fit: Option<Affine>,
@@ -872,7 +886,19 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("artboard.setProps", "an artboard is listed twice"));
}
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// The background color: a color sets it, null clears it, absent leaves it as it is.
let color = artboard_color(q, "artboard.setProps")?;
let now = rect_from(q, was);
let lock = q.get("locked").and_then(Value::as_bool);
// A locked artboard keeps its rectangle unless this unlocks it; its name can change.
let reshaped = [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6);
if reshaped && lock != Some(false) {
super::panelcmds::refuse_locked(d, &[index])?;
}
// Values within 1e-6 pt of a locked artboard's (as Artboard Options passes them) leave its
// rectangle exactly as it is.
let held = lock != Some(false) && d.artboards.get(index).is_some_and(|a| a.locked);
let now = if held { was } else { now };
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
let fit = if scale_art && resized { rect_map(was, now) } else { None };
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
@@ -880,8 +906,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let delta = now.origin() - was.origin();
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art });
let art = if transform.is_some() { d.art_moving_with(index, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), color, lock, delta, resized, fit, transform, art });
}
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
// the preference is then.
@@ -900,6 +926,12 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() };
}
if let Some(c) = e.color {
a.color = c;
}
if let Some(l) = e.lock {
a.locked = l;
}
let id = a.id;
if let Some(xf) = e.transform {
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
@@ -937,14 +969,19 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
/// template layers don't count. An artboard with no art on it stays as it is, and the command
/// fails when none has art. The fits are one undo step named Artboard Options.
/// template layers don't count. An artboard with no art on it, or a locked one, stays as it is,
/// and the command fails when every one it names is locked or none has art. The fits are one undo
/// step named Artboard Options.
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let indices =
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
let unlocked: Vec<usize> = indices.iter().copied().filter(|i| d.artboards.get(*i).is_some_and(|a| !a.locked)).collect();
if unlocked.is_empty() {
super::panelcmds::refuse_locked(d, &indices)?;
}
let mut fits = vec![];
for i in indices {
for i in unlocked {
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
+6 -9
View File
@@ -932,16 +932,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
/// already open (its own step then holds them).
/// Run `f`'s commands as one undo step, or as part of the step of an undo group open in the active
/// document, and undo them together when it fails ([`Session::begin_undo_scope`]).
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
let open = s.active().is_some_and(|st| st.undo_group.is_some());
if !open {
s.begin_undo_group();
}
let scope = s.begin_undo_scope();
let r = f(s);
if !open {
s.end_undo_group(r.is_err());
if let Some(scope) = scope {
s.end_undo_scope(scope, r.is_err());
}
r
}
@@ -1091,7 +1088,7 @@ fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
let (name, at) = s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
let g = lib.groups.remove(i);
let at = usize::try_from(to).unwrap_or(usize::MAX).min(lib.groups.len());
let at = index(to).min(lib.groups.len());
let name = g.name.clone();
lib.groups.insert(at, g);
Ok((name, at))
+18 -12
View File
@@ -411,6 +411,7 @@ const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
check_numbers(p, &["value", "steps", "distance"], cmd)?;
let smooth = switch_or(p, "smooth", false, cmd)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
let mode = str_param(p, "spacing");
@@ -428,7 +429,7 @@ fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpa
steps("steps", n)?
} else if let Some(d) = num("distance") {
BlendSpacing::Distance(d)
} else if bool_or(p, "smooth", false) {
} else if smooth {
BlendSpacing::SmoothColor
} else {
current
@@ -643,7 +644,7 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
Some("in") => Some(false),
Some(_) => return Err(bad(C, "handle must be in or out")),
};
let independent = bool_or(p, "independent", false);
let independent = switch_or(p, "independent", false, C)?;
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
if let Some(out) = handle {
@@ -697,12 +698,12 @@ fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(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 !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
let perpendicular = bool_or(p, "perpendicular", false);
let perpendicular = switch_or(p, "perpendicular", false, C)?;
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
@@ -991,10 +992,11 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0);
h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0);
v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0);
let given = switch_or(p, "horizontal", horizontal, cmd)?;
horizontal = match str_param(p, "orientation") {
Some("vertical") => false,
Some("horizontal") => true,
_ => bool_or(p, "horizontal", horizontal),
_ => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
}
@@ -1119,7 +1121,7 @@ fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithMesh";
let maintain = bool_or(p, "maintainShape", true);
let maintain = switch_or(p, "maintainShape", true, C)?;
let kinds = reshaped(s, |env, kind| {
let own = match kind {
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
@@ -1150,11 +1152,11 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeO
None => cur.preserve_shape,
};
Ok(EnvelopeOptions {
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?,
preserve_shape,
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?,
distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?,
distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?,
})
}
@@ -1249,6 +1251,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.options";
check_numbers(p, &["fidelity"], C)?;
check_numbers(p, WARP_NUMBERS, C)?;
check_switches(p, &["horizontal"], C)?;
let fid = p.get("fidelity").and_then(Value::as_f64);
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
return Err(bad(C, "fidelity must be 0..100"));
@@ -1330,9 +1333,10 @@ fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
}
fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.editContents";
let envs = selected_of(s, is_envelope);
let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. }));
let want = bool_or(p, "editing", !cur);
let want = switch_or(p, "editing", !cur, C)?;
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
let mut content_ids = vec![];
for e in &envs {
@@ -1535,6 +1539,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.setPointColor";
check_numbers(p, &["opacity"], C)?;
super::paint::check_keep_model(p, C)?;
let index = index_param(p, "index", C)?;
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let opacity = p.get("opacity").and_then(Value::as_f64);
@@ -1573,6 +1578,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
super::paint::check_keep_model(p, C)?;
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
+44 -24
View File
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rearrange All Artboards…",
["Object", "Artboards"],
None,
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's → {artboards, rows, columns}",
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
has_doc,
rearrange_artboards
),
@@ -348,11 +348,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7);
let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7);
let rot = f64_or(p, "rotate", 0.0);
let rx = bool_or(p, "reflectX", false);
let ry = bool_or(p, "reflectY", false);
let random = bool_or(p, "random", false);
let rx = switch_or(p, "reflectX", false, "object.transformEach")?;
let ry = switch_or(p, "reflectY", false, "object.transformEach")?;
let random = switch_or(p, "random", false, "object.transformEach")?;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, "object.transformEach")?;
let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero"));
@@ -598,7 +598,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
let gray = bool_or(p, "gray", false);
let gray = switch_or(p, "gray", false, C)?;
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
@@ -613,7 +613,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9);
let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9);
let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9));
let delete = bool_or(p, "deleteRaster", false);
let delete = switch_or(p, "deleteRaster", false, C)?;
let (gid, n) = s.edit("Create Object Mosaic", |d, sel| {
let mut tiles = vec![];
for r in 0..rows {
@@ -670,7 +670,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(C, "select an image"))?;
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
if p.get("trim").and_then(Value::as_bool) == Some(true) {
if switch_or(p, "trim", false, C)? {
return trim_image(s, id, (**im).clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
@@ -836,9 +836,9 @@ impl Session {
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.createTrimMarks";
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
let style = match str_param(p, "style") {
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
None => s.crop_mark_style(),
let style = match p.get("style") {
None | Some(Value::Null) => s.crop_mark_style(),
Some(v) => v.as_str().and_then(MarkStyle::parse).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
};
let d = TrimMarks::default();
let marks = TrimMarks {
@@ -849,7 +849,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
weight: f64_or(p, "weight", d.weight).max(0.01),
};
let st = s.doc()?;
let rects: Vec<Rect> = if bool_or(p, "allArtboards", st.selection.is_empty()) {
let rects: Vec<Rect> = if switch_or(p, "allArtboards", st.selection.is_empty(), C)? {
st.doc.artboards.iter().map(|a| a.rect).collect()
} else {
st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect()
@@ -899,7 +899,14 @@ pub(crate) fn detect_shape(path: &PathData) -> Option<LiveShape> {
}
let rot = Affine::rotate(e0.y.atan2(e0.x));
let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY };
return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
return Some(LiveShape::Rectangle {
w,
h,
radii: [0.0; 4],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate(a[0].p.to_vec2()) * rot * flip,
});
}
let b = path.bounds()?;
let c = b.center();
@@ -964,8 +971,10 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
id: aid,
name: format!("Artboard {aid}"),
rect: b,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
converted.push(id);
}
@@ -984,9 +993,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
use super::newdoc::ArtboardLayout as L;
// A value given (not null) must be one of the names.
let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default());
let by_column = switch_or(p, "byColumn", false, CMD)?;
let layout = match named("layout") {
Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?,
None if bool_or(p, "byColumn", false) => L::GridByColumn,
None if by_column => L::GridByColumn,
None => L::GridByRow,
};
let rtl = match named("order") {
@@ -1007,29 +1017,39 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
};
check_numbers(p, &["spacing"], CMD)?;
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let move_art = bool_or(p, "moveArtwork", true);
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
let locked_and_hidden = s.prefs.move_locked_with_artboard;
let d = &s.doc()?.doc;
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
let first = rects.first().map_or(Point::ZERO, |r| r.origin());
let origins = super::newdoc::grid_origins_rc(&sizes, first, (rows, cols), spacing, layout == L::GridByColumn, rtl);
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
// A locked artboard stays where it is, and keeps its place in the grid.
let deltas: Vec<Vec2> =
rects.iter().zip(origins).zip(&d.artboards).map(|((r, o), a)| if a.locked { Vec2::ZERO } else { o - r.origin() }).collect();
// When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and
// its undo history stay as they are.
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
}
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
// hidden art moves only when prefs moveLockedWithArtboard is on.
// With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the
// artboard its center is on, with symbol instances measured by their art as
// `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked
// artboard, moves only when prefs moveLockedWithArtboard is on.
fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() { objects(c, all, out) } else { out.push(c) }
}
}
let locks = d.locked_artboards();
let symbols = d.symbol_boxes();
let art: Vec<(NodeId, Vec2)> = if move_art {
d.layers
.iter()
.filter(|l| l.rides_with_artboard(locked_and_hidden))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.rides_with_artboard(locked_and_hidden))
let mut tops = vec![];
for l in d.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
objects(l, locked_and_hidden, &mut tops);
}
tops.into_iter()
.filter(|n| locked_and_hidden || !locks.holds(n))
.filter_map(|n| {
let c = n.geometric_bounds_with(&symbols)?.center();
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
+36 -6
View File
@@ -318,20 +318,42 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
/// Error unless each of `keys` that `p` gives is a number (or null): an optional number read with
/// [`f64_or`] would otherwise fall back to its default for a string such as `"60 mm"` (#1952).
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// Error unless each of `keys` that `p` gives is a number (or null), as [`number_or`] reads them: an
/// optional number read with [`f64_or`] would otherwise fall back to its default for a string such as
/// `"60 mm"` (#1952).
pub(crate) fn check_numbers(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
for key in keys {
match p.get(*key) {
None | Some(Value::Null) | Some(Value::Number(_)) => {}
Some(v) => return Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
number_or(p, key, 0.0, cmd)?;
}
Ok(())
}
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(default)
}
/// The true or false `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any
/// other value, such as `"false"`, is an error: "`{key}` must be true or false".
pub(crate) fn switch_or(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
}
}
/// Error unless each of `keys` that `p` gives is true or false (or null), as [`switch_or`] reads
/// them.
pub(crate) fn check_switches(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
for key in keys {
switch_or(p, key, false, cmd)?;
}
Ok(())
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
@@ -341,6 +363,10 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
}
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
pub(crate) fn index(v: u64) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
let values =
@@ -486,6 +512,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
}
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use object::bounds_rewrite;
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
@@ -525,6 +552,9 @@ pub use flatten::FlattenerPreset;
/// The shapes a group selected alone gives the Pathfinder panel.
pub use pathops::shape_count;
/// The gaps View › Show Live Paint Gaps shows on a Live Paint group.
pub use buildcmds::live_paint_gaps;
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
/// They run as before but the command palette leaves them out (it lists the command once).
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
+2
View File
@@ -338,8 +338,10 @@ impl DocSettings {
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
rect,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
})
.collect();
d
+336 -71
View File
@@ -6,10 +6,11 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::corners::set_corners;
use vectorcraft_doc::corners::{convert_radii_relative, set_corners};
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use vectorcraft_render::effects;
use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform};
use super::opacitymask::percent;
@@ -227,7 +228,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, _| clip_edit(s, true)
),
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id?} (default: the selection's first object)", has_doc, isolate),
cmd!(
"object.isolate",
"Enter Isolation Mode",
[],
None,
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused",
has_doc,
isolate
),
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
cmd!(
"object.setProps",
@@ -252,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
can_distribute,
distribute
),
@@ -261,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected; default: the selection; \"key\" spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the objects equal, in the order of their left (top) edges: the first object's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
can_distribute,
distribute_spacing
),
@@ -270,7 +279,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
has_selection,
set_bounds
),
@@ -280,7 +289,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
"{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -294,7 +303,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
// Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre.
let st = s.doc()?;
if !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
{
return Ok(b.center());
}
@@ -305,10 +314,16 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
/// its `strokes` and `corners` params, else the preferences. The values join the command's journal
/// entry, so replaying it scales the same way whatever the preferences are then.
pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
let sc = scaling_options(s, p);
s.note_journal("strokes", json!(sc.strokes));
s.note_journal("corners", json!(!sc.keep_corners));
sc
}
/// [`scaling`] without noting the values in the journal.
fn scaling_options(s: &Session, p: &Value) -> Scaling {
let strokes = bool_or(p, "strokes", s.prefs.scale_strokes);
let corners = bool_or(p, "corners", s.prefs.scale_corners);
s.note_journal("strokes", json!(strokes));
s.note_journal("corners", json!(corners));
Scaling {
strokes,
effects: strokes.then_some(vectorcraft_render::effects::scale_effect),
@@ -341,6 +356,11 @@ pub(crate) fn transforms_objects(p: &Value) -> bool {
bool_or(p, "objects", true)
}
/// The on/off params of the transform commands that [`apply_transform`], [`scaling`],
/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file
/// check them with [`check_switches`] before they change anything.
const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"];
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off.
pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) {
if objects {
@@ -490,6 +510,8 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?;
check_switches(p, &["anchors"], "object.transform")?;
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (outermost(&s.doc()?.doc, v), false),
@@ -500,12 +522,15 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.move")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.move")?;
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.nudge")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?;
check_switches(p, &["big"], "object.nudge")?;
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
@@ -534,6 +559,8 @@ fn about(o: Point, a: Affine) -> Affine {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.rotate")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?;
check_switches(p, &["absolute"], "object.rotate")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let mut angle = f64_or(p, "angle", 0.0);
@@ -547,6 +574,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["sy"], "object.scale")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
@@ -558,6 +586,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
@@ -578,6 +607,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.shear")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
@@ -590,6 +620,7 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
/// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform
/// Each is repeated by running it again (its transform is per object, about each one's bounds).
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?;
if let Some(again) = s.doc()?.last_perspective.clone() {
return super::distortcmds::transform_again(s, &again);
}
@@ -639,18 +670,24 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
None => by_parent.push((par, vec![*id])),
}
}
for (par, group) in by_parent {
for (par, mut group) in by_parent {
// In a clip group or a clipping layer the bottom child is the clipping path: it stays
// there, and the art above it never goes below it (#3188).
let floor = usize::from(par.and_then(|p| d.node(p)).is_some_and(vectorcraft_doc::Node::clips));
if floor > 0 {
group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor));
}
for (k, id) in group.iter().enumerate() {
let Some((_, idx, len)) = d.position(*id) else { continue };
if len == 0 {
if len <= floor {
continue;
}
let last = len - 1;
let to = match how {
Arrange::Front => last.saturating_sub(k),
Arrange::Back => k.min(last),
Arrange::Front => last.saturating_sub(k).max(floor),
Arrange::Back => (floor + k).min(last),
Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx),
Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)),
Arrange::Backward => idx.saturating_sub(1).max(floor + k.min(idx.saturating_sub(floor))),
};
if to != idx {
d.move_node(*id, par, to)?;
@@ -1047,6 +1084,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let guides_locked = s.menu.guides_locked;
let st = s.doc_mut()?;
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
// isolated: a double-click on it edits its text.
@@ -1054,8 +1092,19 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
// A hidden or locked object, one in a hidden or locked group or layer, and a guide path while
// Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them.
// A Shaper Group isolates its source group, so that group is the one checked.
if !st.doc.is_editable(isolation) {
return Err(bad("object.isolate", "the object is hidden or locked"));
}
if guides_locked && crate::is_guide_path(&st.doc, isolation) {
return Err(EngineError::Other("guides are locked".into()));
}
st.isolation = Some(isolation);
st.selection.clear();
// New art on a locked artboard, now deselected, is protected.
st.sync_new_art();
st.revision += 1;
ok()
}
@@ -1071,6 +1120,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
// The art on a locked artboard can't be selected.
st.protect_locked_artboards(&[], &Default::default(), None);
}
st.revision += 1;
ok()
@@ -1103,6 +1154,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
let blend = match str_param(p, "blend") {
@@ -1203,14 +1255,15 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
impl Session {
/// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure:
/// visual bounds (strokes included) with Use Preview Bounds on, else geometric bounds.
/// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included,
/// see [`effects::preview`]), else geometric bounds.
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds)
}
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
/// box, see [`Document::oriented_bounds`]).
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds)
}
}
@@ -1246,9 +1299,9 @@ fn spacing(p: &Value, cmd: &str) -> Result<Option<f64>> {
.transpose()
}
/// `ids` with their bounds (`preview`: visual bounds).
/// `ids` with their bounds (`preview`: visual bounds as drawn, see [`effects::preview::bounds_of`]).
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
ids.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], preview)?))).collect()
}
/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position)
@@ -1312,7 +1365,7 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
let st = s.doc()?;
match align_to(s, p) {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).unwrap_or_default();
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there.
@@ -1322,9 +1375,9 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
}
Some("key") => {
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
st.doc.bounds_of(&[k], preview).ok_or(EngineError::NoNode(k))
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
}
_ => st.doc.bounds_of(ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
}
}
@@ -1353,6 +1406,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let preview = preview_bounds(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
check_switches(p, &["patterns"], "object.align")?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
@@ -1467,10 +1521,68 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
ok()
}
/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they
/// may all be on one path).
/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct
/// Selection (they may all be on one path).
fn can_distribute(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) }
if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) {
Ok(())
} else {
has_multi(s)
}
}
/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide
/// across the axis (#1597), which has no size.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Spaced {
Object(NodeId),
Guide(usize),
}
/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected
/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds.
/// Guides along the axis are left out, so they stay where they are.
fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result<Vec<(Spaced, Rect)>> {
let st = s.doc()?;
let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r));
let guides = st.selection.guides.iter().filter_map(|&i| {
let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?;
Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos)))
});
Ok(objects.chain(guides).collect())
}
/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing:
/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when
/// aligning to the key (#2143).
fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option<usize> {
let key = match key_root(s, ids) {
Some(id) => Spaced::Object(id),
None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?,
};
items.iter().position(|(it, _)| *it == key)
}
/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution),
/// aligning to the key. It has no line there, so a spacing from it leaves everything where it
/// is, as Align does.
fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool {
key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz)
}
/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as
/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position.
fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result<Value> {
let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) };
let (mut moves, mut guides) = (vec![], vec![]);
let d = &s.doc()?.doc;
for &(it, delta) in deltas {
match it {
Spaced::Object(id) => moves.push((id, along(delta))),
Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))),
}
}
apply_moves(s, label, p, ids, &moves, &guides)
}
/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them
@@ -1513,6 +1625,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distribute")?;
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
@@ -1524,7 +1637,6 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), None),
};
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
(Some(h), _) => (
true,
@@ -1544,44 +1656,50 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
),
_ => return Err(bad("object.distribute", "give horizontal or vertical")),
};
// The selected guides across the axis are spaced with the objects (#1597). A guide has no
// size, so its left edge, center and right edge are all its line.
let mut items = spaced_items(s, &ids, preview, horiz)?;
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
// With a spacing, the key object (or the compound path or selected group it is in) stays where
// it is, and the lines are placed `spacing` apart, outward from its line, in their order.
// Without a key object the spacing is not used. `from_key` is the key's index in `items` and
// the spacing.
let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g)));
// Two objects are enough on an artboard, where they go to its edges, and when they are spaced
// from a key object.
if spacing.is_some() && key_guide_along(s, p, horiz) {
return ok();
}
// With a spacing, the key object (or the compound path or selected group it is in) or the key
// guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in
// their order. Without a key the spacing is not used. `from_key` is the key's index in
// `items` and the spacing.
let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g));
// Two items are enough on an artboard, where they go to its edges, and when they are spaced
// from a key.
if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) {
return ok();
}
let (first_box, last_box) = (&items[0].1, &items[n - 1].1);
// `first` and `last` are the lines that the first and last objects end on. Without an artboard
// the first and last objects stay where they are. On an artboard the first object moves until
// its left (top) edge is on the artboard's left (top) edge, and the last object moves until its
// `first` and `last` are the lines that the first and last items end on. Without an artboard
// the first and last items stay where they are. On an artboard the first item moves until
// its left (top) edge is on the artboard's left (top) edge, and the last item moves until its
// right (bottom) edge is on the artboard's right (bottom) edge.
let (first, last) = match board {
Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1),
Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1),
None => (key(first_box, horiz), key(last_box, horiz)),
};
let moves: Vec<(NodeId, Vec2)> = items
let deltas: Vec<(Spaced, f64)> = items
.iter()
.enumerate()
.map(|(i, (id, r))| {
.map(|(i, (it, r))| {
let target = match from_key {
Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64),
None => first + (last - first) * i as f64 / (n - 1) as f64,
};
let delta = target - key(r, horiz);
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
(*it, target - key(r, horiz))
})
.collect();
apply_moves(s, "Distribute", p, &ids, &moves, &[])
apply_spaced(s, "Distribute", p, &ids, horiz, &deltas)
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distributeSpacing")?;
let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
@@ -1590,16 +1708,21 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
// The selected guides across the axis are spaced with the objects, as items with no size (#1597).
let mut items = spaced_items(s, &ids, preview, horiz)?;
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
if spacing.is_some() && key_guide_along(s, p, horiz) {
return ok();
}
let n = items.len();
let &[(_, first), .., (_, last)] = items.as_slice() else {
return Err(EngineError::Other("select two or more objects to distribute".into()));
// Selected guides along the axis have nothing to space.
return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() };
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
// The objects start at `lo`, and without a spacing the last one ends at `hi`. These are the
// first object's left (top) edge and the last one's right (bottom) edge, or the artboard's
// The items start at `lo`, and without a spacing the last one ends at `hi`. These are the
// first item's left (top) edge and the last one's right (bottom) edge, or the artboard's
// edges with Align to Artboard and no spacing.
let (lo, hi) = match board.filter(|_| spacing.is_none()) {
Some(r) if horiz => (r.x0, r.x1),
@@ -1613,47 +1736,84 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut pos = lo;
let mut deltas = vec![];
for (id, r) in &items {
deltas.push((*id, pos - start(r)));
for (it, r) in &items {
deltas.push((*it, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is, with the
// compound path or selected group it is in: the others are spaced from it.
let key = key_root(s, &ids);
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[])
// With a spacing the key stays where it is: the key object, with the compound path or
// selected group it is in, or the key guide (#2143). The others are spaced from it.
let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1);
let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect();
apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas)
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
match bounds_transform(s, p, selected_roots(s)?, 0.0)? {
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
}
}
/// What `object.setBounds` does with its values ([`bounds_transform`]).
enum Bounds {
/// With anchors selected with Direct Selection: the objects and the box of the anchors that
/// [`set_anchor_bounds`] moves or scales.
Anchors(Vec<NodeId>, Rect),
/// The objects and the transform that gives them the values.
Objects(Vec<NodeId>, Affine),
}
/// A previewed `object.setBounds` that transforms objects runs as `object.transform` with the
/// transform [`bounds_transform`] finds for it, so the interaction commits that transform (Transform
/// Again repeats it, and the journal holds it). A bounding-box handle drag with Use Preview Bounds
/// previews `object.setBounds`. The objects are those `object.transform` transforms (a path of a
/// compound path selected on its own is one), and the art keeps at least 1e-4 of its width and
/// height, as with [`vectorcraft_tools::bbox::scale_for_drag`]. `None` with anchors selected with
/// Direct Selection and when the values are not valid.
pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
if cmd != "object.setBounds" {
return None;
}
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None };
let mut p = params.as_object().cloned().unwrap_or_default();
for k in ["x", "y", "width", "height", "reference", "proportional"] {
p.remove(k);
}
p.insert("matrix".into(), json!(xf.as_coeffs()));
Some(("object.transform".to_string(), Value::Object(p)))
}
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
/// art with a width or height below `least` of the one it has keeps `least` of it.
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
let ids = selected_roots(s)?;
let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?;
if transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
{
return set_anchor_bounds(s, p, ids, b);
return Ok(Bounds::Anchors(ids, b));
}
let d = &s.doc()?.doc;
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
// the math below runs in its frame.
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let g = frame.rect;
// With Use Preview Bounds the values measure the visual box, whose margins around the
// With Use Preview Bounds the values measure the visual box as drawn, whose margins around the
// geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of
// `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their
// weight when an instance scales, so the rest of the margins stay.
let (v, vs) = if s.prefs.use_preview_bounds {
(d.oriented_bounds(&ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
(effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
} else {
(g, g)
};
let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1];
let (m, all) = (margins(vs), margins(v));
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
let rp = vectorcraft_geom::reference_point(v, refi);
let (w, h) = wanted_size(p, v);
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
@@ -1689,20 +1849,112 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
return Ok(Bounds::Objects(ids, Affine::IDENTITY));
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
// The scales and the point above can miss the size or the reference point when the margins of
// the objects differ, the members of groups and the objects in symbols' art included, and with
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
// by one ([`Spans::measure`]), and the scales solved for.
let solved = if s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0 {
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
} else {
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are
// measured as drawn after each scale tried instead ([`refit_drawn`]).
let symbols = d.symbol_boxes();
let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n));
let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) {
None
} else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() {
let options = scaling_options(s, p);
let to_local = frame.to_doc().inverse();
let measure = |sx: f64, sy: f64| {
let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy)));
let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() };
ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| {
let mut n = n.clone();
n.transform(xf, sc);
vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local))
})
};
// The scales above keep the effects' reach as a margin that stays, and give 0 for a size
// smaller than that reach. Strokes and effects that scale shrink the reach with the art, so
// the search then starts from the size wanted over the size as drawn.
let seed = |s: f64, size: f64, now: f64| if s <= 0.0 && size > 0.0 && now > 1e-9 { size / now } else { s };
refit_drawn(measure, refi, [w, h], (seed(sx, w, v.width()), seed(sy, h, v.height())), [g.width() > 1e-9, g.height() > 1e-9])
} else {
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
};
let (scale, new_rp) = solved.map_or((scale, new_rp), |(sx, sy, at)| (about(rp, Affine::scale_non_uniform(sx, sy)), at));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
let (sx, sy, new_rp) = solved.unwrap_or((sx, sy, new_rp));
// A scale below `least` of art with a width (height) keeps `least`, and the reference point
// moves with the scales as above.
let keep = |s: f64, geo: f64| if geo > 1e-9 && (0.0..least).contains(&s) { least } else { s };
let (kx, ky) = (keep(sx, g.width()), keep(sy, g.height()));
let scale = about(rp, Affine::scale_non_uniform(kx, ky));
let new_rp = if (kx, ky) == (sx, sy) { new_rp } else { scale * gp + (sp - gp) * f(kx, ky) + (rp - sp) };
let xf = frame.conjugate(Affine::translate(to - new_rp) * scale);
Ok(Bounds::Objects(ids, xf))
}
/// The scales along x and y that give the box `measure` returns for them the size `size`, and point
/// `refi` of that box: the point that goes to the reference point's place. The search starts at
/// `start`. Each step measures how the width and the height change with each scale that `free`
/// marks true, by measuring again with that scale moved by 1e-4 of it (at least 1e-6), and moves
/// those scales by the least-squares solution of that linear change, never below 0. The width can
/// change with the y scale and the height with the x scale (a geometry effect can turn the art). A
/// scale that `free` marks false stays as it is. The search stops when both sizes are within 1e-9
/// of the sizes wanted (of the largest of 1 and those sizes), when no scale moves, when `measure`
/// finds no box, or after 16 steps, and returns the scales that came nearest. `None` when `measure`
/// finds no box for `start`.
fn refit_drawn(
measure: impl Fn(f64, f64) -> Option<Rect>,
refi: usize,
size: [f64; 2],
start: (f64, f64),
free: [bool; 2],
) -> Option<(f64, f64, Point)> {
let [w, h] = size;
let tolerance = 1e-9 * w.max(h).max(1.0);
let miss = |b: Rect| (b.width() - w).abs() + (b.height() - h).abs();
let (mut sx, mut sy) = start;
let mut b = measure(sx, sy)?;
let mut best = (sx, sy, b);
for _ in 0..16 {
let f = [b.width() - w, b.height() - h];
if f[0].abs() <= tolerance && f[1].abs() <= tolerance {
break;
}
// How the width and the height change with the x scale (`a`) and the y scale (`c`).
let change = |x: bool| -> Option<[f64; 2]> {
let (on, s) = if x { (free[0], sx) } else { (free[1], sy) };
if !on {
return Some([0.0, 0.0]);
}
let ds = (s.abs() * 1e-4).max(1e-6);
let m = if x { measure(sx + ds, sy) } else { measure(sx, sy + ds) }?;
Some([(m.width() - b.width()) / ds, (m.height() - b.height()) / ds])
};
let (Some(a), Some(c)) = (change(true), change(false)) else { break };
let dot = |u: [f64; 2], v: [f64; 2]| u[0] * v[0] + u[1] * v[1];
let (aa, ac, cc, af, cf) = (dot(a, a), dot(a, c), dot(c, c), dot(a, f), dot(c, f));
let det = aa * cc - ac * ac;
let (dx, dy) = if det > 1e-12 * aa * cc && det > 0.0 {
((ac * cf - cc * af) / det, (ac * af - aa * cf) / det)
} else if aa >= cc && aa > 0.0 {
(-af / aa, 0.0)
} else if cc > 0.0 {
(0.0, -cf / cc)
} else {
break;
};
let (nx, ny) = ((sx + dx).max(0.0), (sy + dy).max(0.0));
if !(nx.is_finite() && ny.is_finite()) || (nx == sx && ny == sy) {
break;
}
let Some(next) = measure(nx, ny) else { break };
(sx, sy, b) = (nx, ny, next);
if miss(b) < miss(best.2) {
best = (sx, sy, b);
}
}
let (sx, sy, b) = best;
Some((sx, sy, vectorcraft_geom::reference_point(b, refi)))
}
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
@@ -1724,7 +1976,7 @@ fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
/// the anchors, their handles with them, and those objects move or scale.
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
let rp = vectorcraft_geom::reference_point(b, refi);
let (w, h) = wanted_size(p, b);
// A single anchor, or anchors in a line, have no size to scale on that side.
@@ -2069,6 +2321,7 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?;
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) {
None => None,
@@ -2104,6 +2357,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
})
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let relative_radii = p.get("relativeRadii").and_then(Value::as_bool);
let sides = p.get("sides").and_then(Value::as_u64);
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
@@ -2148,6 +2402,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
_ => {}
}
if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) {
return false;
}
if radius.is_none() && kind.is_none() {
return true;
}
@@ -2156,9 +2413,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
// Setting corners folds a scale left in a rectangle's transform into its size, which
// changes it.
live.as_deref().is_none_or(|l| l.folded() == *l)
&& picked
.iter()
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
&& picked.iter().all(|k| {
*k < c.base.anchor_count()
&& radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9)
&& kind.is_none_or(|kd| c.kind(*k) == kd)
})
};
if ids.iter().all(has_values) {
return ok();
@@ -2192,6 +2451,12 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
}
*path = l.to_path();
}
if let Some(on) = relative_radii
&& let Some(l) = live.as_deref_mut()
{
convert_radii_relative(l, on);
*path = l.to_path();
}
if radius.is_none() && kind.is_none() {
continue;
}
+10 -2
View File
@@ -21,7 +21,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
use vectorcraft_doc::objects_on_path::{CENTER, Held, ObjectsOnPath, attachable_path, held, is_objects_on_path, place};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
@@ -168,7 +168,10 @@ fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
if group_around(d, path).is_some() {
return Some("the path is already in an Objects on Path group");
}
None
match held(d, path)? {
Held::Live => Some("a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects"),
Held::Clipping => Some("a clipping path can't take objects"),
}
}
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
@@ -179,6 +182,11 @@ fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
if group_around(d, n.id).is_some() {
return Some("an object already on a path can't be attached again");
}
match held(d, n.id) {
Some(Held::Live) => return Some("objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path"),
Some(Held::Clipping) => return Some("a clipping path can't be attached to a path"),
None => {}
}
let mut graph = false;
n.walk(&mut |c| graph |= c.extras().graph.is_some());
graph.then_some("graphs can't be attached to a path")
+23 -12
View File
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
always,
|s, p| {
s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active);
s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?;
Ok(json!({ "fillActive": s.fill_active }))
}
),
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let paint = Paint::solid(s.last_solid);
apply_to_proxy(s, p, paint)
apply_to_proxy(s, p, paint, "paint.lastColor")
}
),
cmd!(
@@ -117,7 +117,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
apply_to_proxy(s, p, paint)
apply_to_proxy(s, p, paint, "paint.lastGradient")
}
),
cmd!(
@@ -287,8 +287,15 @@ fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
}
/// `stroke` param, defaulting to the proxy that is in front.
fn stroke_param(s: &Session, p: &Value) -> bool {
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
switch_or(p, "stroke", !s.fill_active, cmd)
}
/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must
/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists
/// `keepModel` calls it before it changes anything.
pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> {
check_switches(p, &["keepModel"], cmd)
}
/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
@@ -319,11 +326,13 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
/// model ([`new_color_model`]), converted through the color settings.
/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel`
/// other than true, false or null is an error.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
check_keep_model(p, "paint")?;
let model = new_color_model(s, p);
let new_color = |c: Color| stored_color(model, c);
if bool_or(p, "none", false) {
if switch_or(p, "none", false, "paint")? {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
@@ -391,13 +400,14 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
if p.get("tint").is_some() && p.get("swatch").is_none() {
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
}
check_keep_model(p, cmd)?;
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
apply_paint(s, p, paint, fill)
}
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
let fill = !stroke_param(s, p);
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result<Value> {
let fill = !stroke_param(s, p, cmd)?;
apply_paint(s, p, paint, fill)
}
@@ -406,6 +416,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
let focus = switch_or(p, "focus", true, cmd)?;
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
if p.get("ids").is_none()
@@ -431,7 +442,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
super::typecmd::refresh_bounds(t);
Ok(super::appearance::count_changed(d, &before, &[id]))
})?;
if bool_or(p, "focus", true) {
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
@@ -514,7 +525,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
}
s.paint.stroke = unplaced(&paint);
}
if bool_or(p, "focus", true) {
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
@@ -666,7 +677,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
/// default when there are none) and remember the first target's new paint. The Appearance panel's
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let stroke = stroke_param(s, p);
let stroke = stroke_param(s, p, cmd)?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => super::edit::selected_roots(s)?,
+55 -12
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cut Artboard",
[],
None,
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, true)
),
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
@@ -114,21 +114,37 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s
Ok(indices)
}
/// An error when one of artboards `indices` of `d` is locked: a locked artboard isn't moved,
/// resized or deleted.
pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> Result<()> {
match indices.iter().any(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) {
true => Err(EngineError::Other("the artboard is locked".into())),
false => Ok(()),
}
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut),
/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard
/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
let all = s.prefs.move_locked_with_artboard;
Ok(match (art, moving) {
(false, _) => vec![],
(true, false) => d.art_on_artboard(rect, all),
(true, true) => d.art_moving_with(i, all),
})
}
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
/// counted once, with the first of them it is on (artboards can overlap).
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
let mut seen = std::collections::HashSet::new();
let mut arts = vec![];
for &i in indices {
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
arts.push((i, art));
}
Ok(arts)
@@ -144,7 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
/// their places relative to each other, together right of the last artboard.
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
let arts = artboards_art(s, &indices, art)?;
let arts = artboards_art(s, &indices, art, false)?;
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
@@ -170,11 +186,14 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// a cut then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let arts = artboards_art(s, &indices, art)?;
let arts = artboards_art(s, &indices, art, cut)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= indices.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if cut {
refuse_locked(&st.doc, &indices)?;
}
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
@@ -215,11 +234,12 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
Ok((index, copies))
}
/// Add a copy of artboard `src` at `rect`, with an id of its own and its name, or `<name> copy`
/// (`<name> copy 2`…) when that is taken → its index.
/// Add a copy of artboard `src` at `rect`, unlocked, with an id of its own and its name, or
/// `<name> copy` (`<name> copy 2`…) when that is taken → its index.
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: Rect) -> usize {
let mut a = src.clone();
a.id = d.next_artboard_id();
a.locked = false;
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
a.name = [src.name.clone(), format!("{} copy", src.name)]
.into_iter()
@@ -308,10 +328,13 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"leadingModel",
"charAlign",
"proportionalMetrics",
"tateChuYoko",
"noBreak",
"digits",
"fontAxes",
"burasagari",
"romanHangingPunctuation",
"kashida",
"composer",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
@@ -372,6 +395,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
}),
};
let kashida = match p.get("kashida") {
None => None,
Some(v) => Some(match v.as_str() {
Some("none") => vectorcraft_doc::text::Kashida::None,
Some("short") => vectorcraft_doc::text::Kashida::Short,
Some("medium") => vectorcraft_doc::text::Kashida::Medium,
Some("long") => vectorcraft_doc::text::Kashida::Long,
Some("stylistic") => vectorcraft_doc::text::Kashida::Stylistic,
_ => return Err(bad(C, "`kashida` must be \"none\", \"short\", \"medium\", \"long\" or \"stylistic\"")),
}),
};
let spacing = |k: &str| spacing_param(p, k, C);
let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);
let single_word = match p.get("singleWordJustification") {
@@ -432,6 +466,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
if let Some(v) = flag("tateChuYoko") {
st.tate_chu_yoko = v;
}
if let Some(v) = flag("noBreak") {
st.no_break = v;
}
if let Some(v) = &font_axes {
super::textedit::change_font_axes(st, v);
}
@@ -521,6 +561,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("romanHangingPunctuation") {
para.roman_hanging = v;
}
if let Some(v) = kashida {
para.kashida = v;
}
if let Some(v) = composer {
para.composer = v;
}
+10 -6
View File
@@ -158,7 +158,10 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let h_in = point_param(p, "in");
let h_in = match p.get("in").filter(|h| !h.is_null()) {
None => None,
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
};
let independent = bool_or(p, "independent", false);
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
@@ -178,7 +181,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
// Objects selected whole move as objects, and their pattern tiles move with them when
// `patterns` (default: General › Transform Pattern Tiles) is on.
let whole = s.doc()?.selection.whole();
@@ -226,7 +229,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = bool_or(p, "independent", false);
@@ -244,7 +247,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize;
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
s.edit("Move", |d, _| {
let path = path_mut(d, id)?;
@@ -725,6 +728,7 @@ fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let tol = f64_or(p, "tol", 3.0).max(0.0);
let radius = p.get("radius").and_then(Value::as_f64);
@@ -772,8 +776,8 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
+7 -2
View File
@@ -381,6 +381,8 @@ fn pf_label(op: PathfinderOp) -> &'static str {
PathfinderOp::Crop => "Crop",
PathfinderOp::Outline => "Outline",
PathfinderOp::MinusBack => "Minus Back",
PathfinderOp::HardMix => "Hard Mix",
PathfinderOp::SoftMix => "Soft Mix",
}
}
@@ -946,7 +948,8 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
for l in &d.layers {
visit(l, false, false, false, &mut v, (stray, unpainted, empty_text));
}
v.retain(|id| d.is_editable(*id));
let editable = d.editable();
v.retain(|id| editable(*id));
v
};
if found.is_empty() {
@@ -991,13 +994,15 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
}
// Compound children are handled via their compound.
targets.retain(|id| !matches!(d.parent_of(*id).and_then(|p| d.node(p)).map(|p| &p.kind), Some(NodeKind::Compound { .. })));
let editable = d.editable();
targets.retain(|id| {
*id != cutter_id
&& d.is_editable(*id)
&& editable(*id)
&& d.is_visible(*id)
&& d.index_path(*id).is_some_and(|ip| ip < cutter_pos)
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
});
drop(editable);
let mut out = vec![];
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
for t in targets {
+12 -8
View File
@@ -366,7 +366,7 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
let base = GridDefinition::default();
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
let replace = bool_or(p, "replace", false);
let replace = switch_or(p, "replace", false, C)?;
let mut imported = vec![];
for mut preset in presets {
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
@@ -390,9 +390,13 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "imported": imported }))
}
/// Toggle (or set, with `on`) a view option of the grid.
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
let want = p.get("on").and_then(Value::as_bool);
/// Toggle (or set, with `on`) a view option of the grid for the command `cmd`.
fn view_option(s: &mut Session, p: &Value, cmd: &str, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
// A missing or null `on` toggles the option.
let want = match p.get("on") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "on", false, cmd)?),
};
let mut g = silent(s, |g| {
let f = field(g);
*f = want.unwrap_or(!*f);
@@ -401,19 +405,19 @@ fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &m
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.locked)
view_option(s, p, "perspective.grid.lock", |g| &mut g.locked)
}
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.lock_station)
view_option(s, p, "perspective.grid.lockStation", |g| &mut g.lock_station)
}
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.snap)
view_option(s, p, "perspective.grid.snap", |g| &mut g.snap)
}
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.rulers)
view_option(s, p, "perspective.grid.rulers", |g| &mut g.rulers)
}
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
+15 -7
View File
@@ -19,7 +19,9 @@
//! the color.
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
//! hanging indents, space before and after, and direction (`w:bidi`).
//! hanging indents, space before and after, and direction (`w:bidi`). A paragraph justified with
//! a low, medium or high kashida length (`lowKashida`, `mediumKashida`, `highKashida`) is
//! justified with Short, Medium or Long kashidas; `both` is justified without kashidas.
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
//! style) and the styles it is based on, then the run's character style (else the default
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
@@ -37,6 +39,7 @@
use std::collections::HashMap;
use roxmltree::Node;
use vectorcraft_doc::text::Kashida;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
@@ -162,7 +165,7 @@ fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
pub(crate) fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
@@ -361,8 +364,9 @@ enum Align {
Left,
Center,
Right,
/// Justified, the last line at the start (the right of a right-to-left paragraph).
Both,
/// Justified, the last line at the start (the right of a right-to-left paragraph), with the
/// kashida length of `lowKashida`, `mediumKashida` and `highKashida` (§17.18.44).
Both(Kashida),
/// Justified, the last line too.
Distribute,
Start,
@@ -860,8 +864,8 @@ impl Reader {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
Align::Both if rtl => Justify::JustifyRight,
Align::Both => Justify::JustifyLeft,
Align::Both(_) if rtl => Justify::JustifyRight,
Align::Both(_) => Justify::JustifyLeft,
Align::Distribute => Justify::JustifyAll,
Align::Start => Justify::Auto,
Align::End if rtl => Justify::Left,
@@ -873,6 +877,7 @@ impl Reader {
space_before: points(p.before),
space_after: points(p.after),
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
kashida: if let Align::Both(k) = p.align { k } else { Kashida::None },
..ParaStyle::default()
}
})
@@ -898,7 +903,10 @@ fn para_props(ppr: Node) -> ParaProps {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
Some("both") => Some(Align::Both(Kashida::None)),
Some("lowKashida") => Some(Align::Both(Kashida::Short)),
Some("mediumKashida") => Some(Align::Both(Kashida::Medium)),
Some("highKashida") => Some(Align::Both(Kashida::Long)),
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
Some("start") => Some(Align::Start),
Some("end") => Some(Align::End),
+22 -6
View File
@@ -30,7 +30,7 @@
pub(crate) mod adopt;
pub(crate) mod document;
mod docx;
pub(crate) mod docx;
mod rtf;
mod text;
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@@ -210,12 +210,28 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
}
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
let (mut doc, warnings) = match format.id {
let (mut doc, warnings, pdf) = match format.id {
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
// bounds, below).
// bounds, below). A .ai saved without PDF compatibility is read from its editing data, as
// it opens, and placed as an EPS is: the art on artboard `page`.
"pdf" | "ai" | "ait" if pdf => {
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts };
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
let mut read = fileio::page_document(&src.bytes, page - 1, &o);
// Such a file can have more artboards than placeholder pages.
if page > 1
&& read.is_err()
&& let Err(e) = fileio::page_document(&src.bytes, 0, &o)
&& fileio::is_placeholder(&e)
{
read = Err(e);
}
let (mut doc, warnings, alone) =
fileio::page_or_editing_data(src.name, &src.bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
if alone {
// Linked images show their files, found from its folder.
super::links::resolve(&mut doc, src.path, false);
}
(doc, warnings, !alone)
}
_ => {
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
@@ -233,7 +249,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings)
(l.doc, l.warnings, false)
}
};
doc.drop_edit_modes();
+1
View File
@@ -228,6 +228,7 @@ pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocS
t.set_paragraph_styles(story.paras);
}
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
t.system_layout = setup.system_layout;
crate::cmd::typecmd::refresh_bounds(&mut t);
t
}
+50 -1
View File
@@ -11,6 +11,7 @@
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Effect, Node};
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
use super::*;
@@ -58,6 +59,10 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
}
/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its
/// parameters and `_context`, and applies its output as one undo step ([`objects::apply_output`];
/// effect records as [`admit_effects`] admits them). → `{plugin, ids}`: what it made or changed,
/// which is then selected.
fn run(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
@@ -85,13 +90,57 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let out = objects::decode(&plugin.run(&input, &params).map_err(err)?).map_err(err)?;
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?;
let ids = objects::apply_output(d, &inputs, out, parent, &admit_effects).map_err(err)?;
sel.set(ids.iter().copied());
Ok(ids)
})?;
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Admits the effect records a filter returned for object `n` (written, its effects still its own)
/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed
/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object
/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own
/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass,
/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the
/// host read one.
fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result<Vec<Effect>, String> {
use vectorcraft_render::effects;
const C: &str = "plugin.run";
let mut out = Vec::with_capacity(fx.len());
for (j, e) in fx.iter().enumerate() {
if n.appearance.effects.contains(e) {
out.push(e.clone());
continue;
}
let refuse = |why: String| format!("[{j}] `{}`: {why}", e.id);
let params = if e.params.is_null() { json!({}) } else { e.params.clone() };
match effect::installed(&e.id) {
Some(plugin) => {
plugin.manifest().resolve_params(&params).map_err(|why| refuse(why.to_string()))?;
}
None => super::params::check_effect_params(C, &e.id, &params).map_err(|why| refuse(why.to_string()))?,
}
if super::effectcmd::texture_load_effect(&e.id)
&& let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE)
&& !t.as_str().is_some_and(|t| effects::pixel::texture(t).is_some())
{
return Err(refuse("`textureImage` must be a texture as effect.list gives it".into()));
}
let mut admitted = effects::new_effect(&e.id, &params).ok_or_else(|| refuse("no such effect".into()))?;
if effects::is_three_d(&e.id) {
effects::validate_three_d(n, &e.id, &admitted.params).map_err(refuse)?;
}
admitted.visible = e.visible;
out.push(admitted);
}
let items = n.appearance.items.iter().flat_map(|i| i.effects());
if out.iter().chain(items).filter(|e| effects::is_three_d(&e.id)).count() > 1 {
return Err(super::effectcmd::ONE_3D.into());
}
Ok(out)
}
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Setup",
[],
None,
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
has_doc,
setup
),
+1 -1
View File
@@ -96,7 +96,7 @@ fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?;
let picked = match p.get("artboard").filter(|v| !v.is_null()) {
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
let i = v.as_u64().map(index).ok_or_else(|| bad(SET, "artboard must be a 0-based index"))?;
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
}
None => region_holding(&regions, at).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
+3 -3
View File
@@ -156,11 +156,11 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let unit_colors = |g: &[&Used]| g.iter().map(|u| u.color).collect::<Vec<_>>();
let rows: Vec<Vec<&Used>> = match p.get("colors").and_then(Value::as_u64) {
Some(n) if (n as usize) < units.len() => {
let rows: Vec<Vec<&Used>> = match p.get("colors").and_then(Value::as_u64).map(index) {
Some(n) if n < units.len() => {
let keys: Vec<Color> = units.iter().map(|g| method.key_color(&unit_colors(g)).unwrap_or_default()).collect();
let weights: Vec<f32> = units.iter().map(|g| g.iter().map(|u| u.count as f32).sum()).collect();
cluster(&keys, &weights, n as usize).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect()
cluster(&keys, &weights, n).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect()
}
_ => units,
};
+14 -2
View File
@@ -252,6 +252,18 @@ impl FolderStore {
}
}
/// Create area folder `dir`, and the store's folder when it is missing. On Unix every folder
/// made here is owner-only (`0o700`): the copies are unsaved documents, and with the default
/// mode other accounts on the machine could read them. A store folder that already exists
/// keeps its mode, since it can be one the user picked in Preferences.
fn create_area(dir: &std::path::Path) -> std::result::Result<(), String> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
}
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
let entries = match std::fs::read_dir(dir) {
@@ -285,7 +297,7 @@ impl RecoveryStore for FolderStore {
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
let f = self.file(name)?;
if let Some(dir) = f.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
Self::create_area(dir)?;
}
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
}
@@ -304,7 +316,7 @@ impl RecoveryStore for FolderStore {
}
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
let dir = self.area(area)?;
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
Self::create_area(&dir)?;
let path = dir.join(LOCK_FILE);
let file = std::fs::OpenOptions::new()
.read(true)
+2 -2
View File
@@ -266,7 +266,7 @@ fn all(s: &mut Session, _: &Value) -> Result<Value> {
fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
let i = p.get("artboard").and_then(Value::as_u64).map_or(0, index);
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
// Symbol instances are measured by their art.
let symbols = st.doc.symbol_boxes();
@@ -644,7 +644,7 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
let ap = &refn.appearance;
let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item());
let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item());
let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) {
(Some(it), _) => Attribute::Item(it.clone()),
(None, Some(e)) => Attribute::Effect(e.clone()),
+4 -2
View File
@@ -511,10 +511,11 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let editable = st.doc.editable();
let eligible: Vec<NodeId> = ids
.into_iter()
.filter(|id| {
st.doc.is_editable(*id)
editable(*id)
&& st.doc.node(*id).is_some_and(|n| {
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
&& (n.extras().shaper.is_some() || b::node_outline(n).is_some())
@@ -691,10 +692,11 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
/// When `within` is given, only that group is considered.
fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeId>) -> Option<NodeId> {
let mut groups = vec![];
let editable = d.editable();
d.walk(|n| {
if n.extras().shaper.is_some()
&& within.is_none_or(|g| g == n.id)
&& d.is_editable(n.id)
&& editable(n.id)
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
{
+1 -1
View File
@@ -409,7 +409,7 @@ fn splits(lo: f64, hi: f64, count: Option<u64>, size: Option<f64>) -> std::resul
(None, Some(_)) => return Err("a slice size must be a positive number of points".into()),
(None, None) => 1,
};
if n == 0 || n as usize > MAX_PIECES {
if n == 0 || index(n) > MAX_PIECES {
return Err(format!("divide into 1 to {MAX_PIECES} slices"));
}
let step = match (count, size) {
+15 -3
View File
@@ -4,14 +4,14 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile,
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile,
};
use super::appearance::{ItemTarget, edit_items, item_target};
use super::paint::painted;
use super::*;
use crate::DocState;
use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke};
use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke, scale_layer};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -171,6 +171,16 @@ impl StrokeChange {
self.apply(&mut st);
style.set_stroke_layer(&st);
}
/// [`Self::apply_char`] to type drawn at mean scale `k` (its `xf`'s): the weight and dash
/// lengths are document-space, the character stroke is drawn in text space.
pub(crate) fn apply_char_scaled(&self, style: &mut CharStyle, k: f64) {
let mut st = style.stroke_layer();
scale_layer(&mut st, k);
self.apply(&mut st);
scale_layer(&mut st, 1.0 / k);
style.set_stroke_layer(&st);
}
}
/// Does a Stroke panel edit find nothing selected (and no `ids`)? It then sets up new art.
@@ -227,7 +237,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style));
// Character strokes are drawn in text space, scaled by the type's transform.
let k = Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0);
t.runs.iter_mut().for_each(|r| change.apply_char_scaled(&mut r.style, k));
return Ok(());
}
if index.is_none() && n.appearance.stroke().is_none() {
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff. Show more