Compare commits

...
Author SHA1 Message Date
BFlat 035f1cb2f6 Merge pull request #2951 from Hamza0101/inch-ruler-binary-ticks
Rulers: an inch is divided in half, not in ten
2026-10-20 00:02:15 +07:00
BFlat e52b07105d Merge pull request #3107 from FractalEncrypt/fix/status-bar-layout
Keep status-bar messages and readouts stable
2026-10-20 00:01:57 +07:00
Hamza 00f1231334 Tab stops: Shift snaps to the ruler's binary inch ticks
`shift_drag_snaps_a_stop_to_the_nearest_tick` landed while this branch was out
and asserts the tick an inch ruler used to carry: "the ticks are a tenth of an
inch (7.2 pt) apart", snapping 100 pt to 100.8. That tenth is what this change
removes -- an inch ruler divides the inch in half, not in ten.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Part of #2545

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <[email protected]>
2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 49f7f5d7d8 G1: busy cursor during background open, save and export
Co-authored-by: Cursor <[email protected]>
2026-10-19 00:26:27 +07:00
BFlat aa9c70a7b1 Merge pull request #2956 from storytold/fix/zh-hant-param-errors
i18n: Traditional Chinese and Georgian rows for the parameter-check and pdfInfo errors (#2930, #2784, #2914)
2026-10-19 00:23:15 +07:00
136 changed files with 6106 additions and 320 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/crates/affinity/fuzz"
- "/crates/plugins/example"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1
View File
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+4
View File
@@ -134,6 +134,10 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 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 (#2880). 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 | 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 to 1/128) and the minor ticks between them are binary too, while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
+112
View File
@@ -1090,4 +1090,116 @@ mod tests {
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
}
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
/// declares.
const LINUX_TYPES: &[(&str, &str)] = &[
("vectorcraft", "application/x-vectorcraft"),
("drawcraft", "application/x-vectorcraft"),
("svg", "image/svg+xml"),
("svgz", "image/svg+xml-compressed"),
("pdf", "application/pdf"),
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
("ait", "application/x-ai-template"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("tif", "image/tiff"),
("tiff", "image/tiff"),
("bmp", "image/bmp"),
("vctemplate", "application/x-vectorcraft-template"),
("dxf", "image/vnd.dxf"),
("emf", "image/emf"),
("wmf", "image/wmf"),
("eps", "image/x-eps"),
("af", "application/x-affinity"),
("afdesign", "application/x-affinity"),
("afpub", "application/x-affinity"),
];
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
/// other one.
const LINUX_SHARED_TYPES: &[&str] = &[
"image/svg+xml",
"image/svg+xml-compressed",
"application/pdf",
"application/illustrator", // brand-ok: shared-mime-info's type
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/vnd.dxf",
"image/emf",
"image/wmf",
"image/x-eps",
];
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
"af",
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
)];
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
/// have.
#[test]
fn the_linux_desktop_entry_opens_every_readable_format() {
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
let listed: Vec<&str> =
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
let declared: Vec<(&str, Vec<&str>, String)> = mime
.split("<mime-type type=\"")
.skip(1)
.map(|block| {
let body = block.split("</mime-type>").next().unwrap_or_default();
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
let magic = match (body.find("<magic"), body.find("</magic>")) {
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
_ => String::new(),
};
(block.split('"').next().unwrap_or_default(), globs, magic)
})
.collect();
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
associated.sort_unstable();
typed.sort_unstable();
typed.dedup();
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
for (e, t) in LINUX_TYPES {
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
let package = declared.iter().find(|(name, ..)| name == t);
if LINUX_SHARED_TYPES.contains(t) {
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
continue;
}
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
} else {
assert!(globs.contains(e), "{t} has no *.{e} glob");
}
}
for t in &listed {
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
}
for (t, globs, _) in &declared {
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
for g in globs {
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
}
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>

After

Width:  |  Height:  |  Size: 369 B

+4
View File
@@ -129,3 +129,7 @@ sync_github_name = true
[people.makesomethingshit]
sync_github_name = true
[people.MiQ71]
real_name = "Mirko Kerezovic"
sync_github_name = false
+149
View File
@@ -56,6 +56,84 @@ struct Sub {
closed: bool,
}
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
/// relative tolerance rather than exact control-point tests.
struct CircularSub {
centre: Point,
radius: f64,
samples: Vec<Point>,
}
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
let u = 1.0 - t;
let a = u * u * u;
let b = 3.0 * u * u * t;
let c = 3.0 * u * t * t;
let d = t * t * t;
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
}
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
return None;
}
let mut samples = vec![sub.start];
let mut from = sub.start;
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
for i in 1..=8 {
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
}
from = to;
}
if samples.len() < 4 {
return None;
}
let mut anchors = vec![sub.start];
anchors.extend(sub.segs.iter().map(|s| s.end()));
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
anchors.pop();
}
let (p0, p1, p2) = if anchors.len() >= 3 {
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
} else {
(samples[0], samples[samples.len() / 2], *samples.last()?)
};
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
return None;
}
let q0 = p0.x * p0.x + p0.y * p0.y;
let q1 = p1.x * p1.x + p1.y * p1.y;
let q2 = p2.x * p2.x + p2.y * p2.y;
let centre = Point::new(
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
);
let radius = centre.distance(p0);
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
return None;
}
let tolerance = (radius * 5e-4).max(1e-6);
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
return None;
}
Some(CircularSub { centre, radius, samples })
}
fn angle_deg(p: Point, centre: Point) -> f64 {
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
}
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
}
/// The subpaths of `bp` (quadratics raised to cubics).
fn subpaths(bp: &BezPath) -> Vec<Sub> {
let mut out = vec![];
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
pts.pop();
}
self.polyline(&pts, sub.closed, style, width);
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
self.e.entity("SEQEND", end, h, &layer, style, "");
}
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
let geometrically_closed = last.distance(sub.start) <= 1e-9;
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
// before fitting, updating extents or allocating an entity handle.
if sub.closed != geometrically_closed {
return false;
}
let Some(circle) = circular_subpath(sub) else { return false };
let centre = self.to_dxf * circle.centre;
let radius = circle.radius * self.k.abs();
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
return false;
}
// Count sampled points in the drawing extents. For a full circle include all cardinal
// points exactly; for an arc, the dense samples conservatively cover its visible span.
if sub.closed {
let r = circle.radius;
for p in [
Point::new(circle.centre.x - r, circle.centre.y),
Point::new(circle.centre.x + r, circle.centre.y),
Point::new(circle.centre.x, circle.centre.y - r),
Point::new(circle.centre.x, circle.centre.y + r),
] {
self.pt(p);
}
} else {
for p in &circle.samples {
self.pt(*p);
}
}
let layer = self.layer.clone();
let h = self.handle();
if sub.closed {
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
self.e.xyz(10, centre.x, centre.y);
self.e.num(40, radius);
self.e.normal();
return true;
}
let start = self.to_dxf * sub.start;
let end = self.to_dxf * last;
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
let mut a0 = angle_deg(start, centre);
let mut a1 = angle_deg(end, centre);
let am = angle_deg(middle, centre);
if !on_ccw_arc(a0, a1, am) {
std::mem::swap(&mut a0, &mut a1);
}
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
self.e.xyz(10, centre.x, centre.y);
self.e.num(40, radius);
self.e.normal();
if self.o.version.handles() {
self.e.raw(100, "AcDbArc");
}
self.e.num(50, a0);
self.e.num(51, a1);
true
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
+73 -14
View File
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(
kinds(&out),
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
"the fill's outline and hatch, then the stroke, then the circle's stroke"
);
let e = entities(&out);
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
let hatch = &e[1];
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
// The ellipse: four cubics through clamped knots.
let spline = &e[3];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// The circular ellipse is preserved as a native circle (#1299).
let circle = &e[3];
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
// R13 has splines and hatches, and polylines of vertices.
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
// R12: polylines only; the fill is its outline, the ellipse flattened.
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let k = kinds(&r12);
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
}
#[test]
fn circles_and_circular_arcs_are_native_dxf_entities() {
let d = doc_with(|d| {
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
let c = Point::new(120.0, 50.0);
let r = 30.0;
let k = r * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + r, c.y));
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
vec![
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
}
// An actual ellipse is not close enough to a circle and remains a spline.
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
}
#[test]
fn r12_stroked_circles_keep_polyline_width() {
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(kind(&e[0]), "POLYLINE");
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
}
#[test]
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
let d = doc_with(|d| {
let c = Point::new(50.0, 50.0);
let r = 30.0;
let k = r * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + r, c.y));
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
arc.close_path();
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let modern = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&modern), ["SPLINE"]);
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// Preserve Appearance outlines only the strokes a line can't draw.
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
st.align = vectorcraft_doc::StrokeAlign::Outside;
}
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
}
@@ -451,7 +510,7 @@ fn crop_leaves_out_art_outside_the_region() {
let d = shapes_doc();
// Only the ellipse (120..180) reaches into 115..200.
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
+7 -1
View File
@@ -62,11 +62,14 @@ pub struct HitOptions {
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
/// bounds.
pub type_path_only: bool,
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
/// on or the guides are hidden.
pub skip_guides: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
}
}
@@ -318,6 +321,9 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
if !c.visible || editable_only && (c.locked || c.is_template()) {
return false;
}
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
return false;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
+72 -3
View File
@@ -36,6 +36,7 @@ pub mod marks;
mod mem;
pub mod metadata;
pub mod node;
pub mod objects_on_path;
pub mod orient;
pub mod overprint;
pub mod pattern;
@@ -215,13 +216,33 @@ impl Unit {
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
pub fn ruler_step(self, zoom: f64) -> f64 {
const STEPS: [f64; 19] =
const DECIMAL: [f64; 19] =
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
// between them grow denser, down to a hundred and twenty-eighth
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
// the left ruler has room for when it spells one down the ruler a character at a time.
// From an inch up the ladder is the shared one.
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
}
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
/// units, points, pixels and picas are read in tenths.
fn ruler_halves(self) -> bool {
matches!(self, Unit::Inches)
}
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
/// as dense as the tenths elsewhere; a tenth of it otherwise.
pub fn ruler_minor(self, step: f64) -> f64 {
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
}
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
let tick = self.ruler_step(zoom) / 10.0 * self.points();
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
(v / tick).round() * tick
}
pub fn from_pt(self, v: f64) -> f64 {
@@ -1560,9 +1581,17 @@ impl Document {
{
dimension.source = None;
}
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
}
// An Objects on Path group names its path and its objects by id: the copy names the copies.
if let Some(e) = n.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.rename(&renamed);
}
n
}
@@ -1803,6 +1832,46 @@ mod tests {
use super::*;
use vectorcraft_geom::shapes;
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
/// are binary fractions now, and the minor ticks between them stay binary too; every other
/// unit is still read in tenths.
#[test]
fn an_inch_ruler_steps_in_binary_fractions() {
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
let s = step(Unit::Inches, zoom);
if s <= 1.0 {
let k = s.recip();
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
}
// A label step always leaves at least 50 px, which is what the ladder is picked for.
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
}
// The steps the report named: tenths became quarters, eighths and sixteenths.
assert_eq!(step(Unit::Inches, 5.0), 0.25);
assert_eq!(step(Unit::Inches, 10.0), 0.125);
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
// An inch and wider is unchanged.
assert_eq!(step(Unit::Inches, 1.0), 1.0);
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
// further only adds ticks. A label therefore never needs more than the six characters of
// "0.0625", which the left ruler has room for when it spells one out a character per line.
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
}
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
// 128ths the report expects to appear unlabelled when zoomed in close.
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
}
fn doc_with_rects() -> (Document, NodeId, NodeId) {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
+3
View File
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
+59
View File
@@ -82,6 +82,31 @@ impl DocMetadata {
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
/// bytes twice (#2880).
///
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
/// encryption: neither may be pinned by the environment.
pub fn output_time() -> Option<i64> {
pinned_or(|n| std::env::var(n).ok(), now_unix)
}
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
/// variable is preferred and that the clock answers whenever it does not.
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
stamp_from_env(get).or_else(clock)
}
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
/// the clock decides — an export is never refused over the environment.
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
@@ -148,4 +173,38 @@ mod tests {
);
assert!(DocMetadata::default().png_text("").is_empty());
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
}
assert_eq!(stamp_from_env(env(None)), None, "unset");
// Another variable never stands in for it.
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
}
/// What a file records: the variable when it is usable, the clock whenever it is not. The
/// environment and the clock are passed in, so no test changes this process's environment --
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
#[test]
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
let clock = || Some(1_000_000);
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
for ignored in [None, Some(""), Some("-1"), Some("later")] {
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
}
// A platform with no clock and no variable has no date to record.
assert_eq!(pinned_or(env(None), || None), None);
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
}
}
+16
View File
@@ -572,6 +572,9 @@ pub struct NodeExtras {
pub intertwine: Option<Box<PathData>>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -590,6 +593,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
dimension: None,
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -724,6 +728,7 @@ impl Serialize for Node {
e.dimension.is_none(),
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -762,6 +767,7 @@ impl Serialize for Node {
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
st.end()
}
}
@@ -821,6 +827,8 @@ struct NodeWire {
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
impl From<NodeWire> for Node {
@@ -840,6 +848,7 @@ impl From<NodeWire> for Node {
dimension: w.dimension,
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
};
Node {
id: w.id,
@@ -1025,6 +1034,7 @@ impl Node {
match &self.kind {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -1251,6 +1261,12 @@ impl Node {
{
dimension.transform(a);
}
// The frames of the objects attached to a path move with them.
if let Some(e) = self.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
+335
View File
@@ -0,0 +1,335 @@
//! Object › Objects on Path: objects attached to a path and spread along it.
//!
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
//! saves and exports as a group. The record lists the objects in their order along the path, each
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
//! one sits on the end of an open path), puts the attachment point of each one
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
//! tilted by [`ObjectsOnPath::rotate`].
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, PathData, Point};
use crate::blend::Spine;
use crate::symbols::SymbolBoxes;
use crate::{Node, NodeId, NodeKind};
/// The center of the nine reference points.
pub const CENTER: u8 = 4;
fn center() -> u8 {
CENTER
}
/// What makes a group an Objects on Path group (see the module docs).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ObjectsOnPath {
/// The child of the group that is the path.
pub path: NodeId,
/// The attached objects, in their order along the path.
#[serde(default)]
pub members: Vec<Member>,
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
/// from the top left of the object's bounds in its frame (4: the center).
#[serde(default = "center")]
pub pivot: u8,
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
/// upright object has its frame's x axis along the path's direction at its attachment point.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub rotate: f64,
}
/// An object attached to the path of an Objects on Path group.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "MemberWire")]
pub struct Member {
pub id: NodeId,
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
pub frame: Affine,
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
/// the frame's x axis before then.
pub angle: f64,
}
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
#[derive(Deserialize)]
struct MemberWire {
id: NodeId,
frame: Affine,
#[serde(default)]
angle: Option<f64>,
}
impl From<MemberWire> for Member {
fn from(w: MemberWire) -> Self {
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
Self { id: w.id, frame: w.frame, angle }
}
}
/// The angle of `frame`'s x axis (radians, the document's axes).
fn x_angle(frame: Affine) -> f64 {
let [a0, a1, ..] = frame.as_coeffs();
a1.atan2(a0)
}
impl Member {
/// `n` in its frame as drawn ([`upright_frame`]).
pub fn new(n: &Node) -> Self {
let frame = upright_frame(n);
Self { id: n.id, frame, angle: x_angle(frame) }
}
}
impl ObjectsOnPath {
/// A record for the objects `members` (in their order along the path) attached to `path`, each
/// in its frame as drawn ([`upright_frame`]).
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
let members = members.iter().map(|n| Member::new(n)).collect();
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
}
/// The frames go with the group, and each frame's angle becomes the transformed path's
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
/// and reflection when it spreads the objects along the transformed path again. A reflection
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
pub fn transform(&mut self, a: Affine) {
let before = tilt(self.rotate);
if a.determinant() < 0.0 && self.rotate != 0.0 {
self.rotate = -self.rotate;
}
let after = tilt(self.rotate);
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
for m in &mut self.members {
m.frame = a * m.frame;
let (sin, cos) = (m.angle - before).sin_cos();
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
m.angle = y.atan2(x) + after;
}
}
}
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
self.path = to(self.path);
for m in &mut self.members {
m.id = to(m.id);
}
}
/// The group's children changed from `before` to `after`: a new child at the index of a member
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
let mut ids = HashMap::new();
for (old, new) in before.iter().zip(after) {
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
ids.insert(old.id, new.id);
}
}
if !ids.is_empty() {
self.rename(&ids);
}
}
}
/// Is `n` an Objects on Path group?
pub fn is_objects_on_path(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
}
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
/// (not a guide) whose first subpath has some length.
pub fn attachable_path(n: &Node) -> Option<&PathData> {
match &n.kind {
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
_ => None,
}
}
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
/// that frame, an object stands square to the path or across it.
pub fn upright_frame(n: &Node) -> Affine {
let a = n.bbox_angle;
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
match n.geometric_bounds() {
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
_ => Affine::IDENTITY,
}
}
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
/// around. `None` for a path without length.
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
let spine = Spine::new(path)?;
let len = spine.length();
if !len.is_finite() || len <= 1e-9 {
return None;
}
let closed = path.subpaths.first().is_some_and(|s| s.closed);
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
}
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
/// counter-clockwise on the page.
fn tilt(rotate: f64) -> f64 {
-rotate.to_radians()
}
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
let det = m.frame.determinant();
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
return None;
}
let mut local = n.clone();
local.transform(m.frame.inverse(), false);
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
}
/// Is `a` the identity, give or take rounding?
fn negligible(a: Affine) -> bool {
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
}
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
/// record lets go of the objects that are no longer in the group, and children it does not list
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
let old = rec.clone();
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
let path_id = rec.path;
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
g.extras_mut().objects_on_path = None;
return true;
};
let path = attachable_path(path).cloned();
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
for c in children.iter() {
if c.id != path_id && listed.insert(c.id) {
rec.members.push(Member::new(c));
}
}
let mut moved = false;
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
let turn = tilt(rec.rotate);
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
let delta = next * m.frame.inverse();
if !negligible(delta) {
Arc::make_mut(c).transform(delta, false);
*m = Member { id: m.id, frame: next, angle: dir + turn };
moved = true;
}
}
}
if rec != old {
g.extras_mut().objects_on_path = Some(Box::new(rec));
return true;
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::{Rect, shapes};
fn rect(id: u64, r: Rect) -> Node {
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
}
#[test]
fn stops_spread_from_the_first_anchor() {
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let s = stops(&open, 4).unwrap();
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let s = stops(&closed, 4).unwrap();
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
pts.sort();
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
}
#[test]
fn upright_frames_round_the_rotation_to_a_right_angle() {
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 90.0;
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 30.0;
let f = upright_frame(&n);
let [a0, a1, ..] = f.as_coeffs();
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
}
#[test]
fn a_symbol_instance_is_placed_by_its_art() {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
let members = [&instance, &other];
let mut g = Node::new(
NodeId(4),
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
);
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
let symbols = d.symbol_boxes();
assert!(place(&mut g, &symbols));
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
let b = placed.geometric_bounds_with(&symbols).unwrap();
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
}
#[test]
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
let mut v = serde_json::to_value(m).unwrap();
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
v.as_object_mut().unwrap().remove("angle");
let back: Member = serde_json::from_value(v).unwrap();
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
}
#[test]
fn a_group_without_its_path_is_a_plain_group() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
let d = crate::Document::new(100.0, 100.0);
assert!(place(&mut g, &d.symbol_boxes()));
assert!(!is_objects_on_path(&g));
}
}
+72 -8
View File
@@ -212,8 +212,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
}
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order.
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
let p = crate::merged_params(&fx.id, &fx.params);
@@ -229,12 +233,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
}
return Some(out);
}
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
let (paint, opacity, blend) = paint_of(item);
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
.iter()
.enumerate()
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
.map(|(_, i)| {
let (area, rule) = region(path, rule, i);
(*i, area, rule)
})
.collect();
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
// and Inflate's grid reaches over the strokes' outlines.
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
let built = if fx.id == INFLATE {
// One eye for every puff, as far away as the highest needs.
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
let mut solids = vec![];
for ((item, _, _), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) => solids.push((item, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
// A loaded file or a shape edited past the limits keeps drawing its source path
// instead of failing the frame; commands validate the same evaluator and report its
// errors.
Err(_) => return None,
}
}
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, _)| {
let (_, opacity, blend) = paint_of(item);
opacity >= 1.0 && blend == BlendMode::Normal
});
let surfaces: Vec<(Vec<f64>, Node)> = solids
.into_iter()
.map(|(item, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item);
(depths, surface(n, f, &paint, opacity, blend, colored))
})
.collect();
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
// opacity mask, and then it stays whole.
let plain = surfaces.iter().all(|(depths, s)| {
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
});
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
}
let mut faces = vec![];
for (depths, s) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
}
}
// A stable sort: faces at the same depth keep the order of the appearance.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+228 -7
View File
@@ -1,6 +1,6 @@
use serde_json::json;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
use super::*;
@@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red, shaded: never brighter, never another hue.
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
// hue.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
@@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
// Extrude & Bevel paints one surface with the top fill.
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
let mut n = square_node();
n.appearance.effects.push(fx(EXTRUDE, json!({})));
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
}
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
fn stroked(b: &BezPath) -> Node {
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
}
/// A `width` × 100 pt rectangle at (100, 100), stroked.
fn stroked_rect(width: f64) -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((100.0 + width, 100.0));
b.line_to((100.0 + width, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
stroked(&b)
}
/// Is `face` in the stroke's blue (rather than the fill's red)?
fn blue(face: &Node) -> bool {
let [r, _, b] = fill_rgb(face);
b > r
}
/// The bounds of the faces' own paths (without their seam strokes).
fn extent(faces: &[&Node]) -> Rect {
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
}
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
}
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
/// fill's, and they are drawn in the stroke's paint.
#[test]
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
for id in [EXTRUDE, INFLATE] {
let mut n = stroked_rect(100.0);
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
let art = solid_art(&n).unwrap();
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
assert!(!strokes.is_empty(), "{id}: the stroke draws");
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
// Its own blue, shaded: never brighter, never another hue.
for f in &strokes {
let [r, g, b] = fill_rgb(f);
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
}
}
}
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
/// drawn below the fill's.
#[test]
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
// caps; the mean of its outline's points is not the mean of the stroke's.
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((400.0, 100.0));
b.line_to((100.0, 200.0));
b.close_path();
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
let mut n = stroked(&b);
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let flat = params["depth"] == json!(0);
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
// The stroke's cap is a ring: its outline and the hole the fill shows through.
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
if !flat {
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
}
}
}
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
#[test]
fn a_stroked_open_path_builds_a_ribbon() {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((200.0, 100.0));
b.line_to((200.0, 200.0));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
assert!(faces.iter().all(|f| blue(f)), "{id}");
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
}
}
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
/// still draws.
#[test]
fn a_stroke_past_the_limits_is_left_out() {
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..1500)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
Point::new(200.0 + r * c, 200.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
n.appearance.effects.push(fx(id, json!({})));
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
}
}
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
#[test]
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
// 1 pt stroke, so the stroke stays flat.
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
let mut n = stroked_rect(length);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.width = width;
}
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
}
}
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
#[test]
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.align = vectorcraft_doc::StrokeAlign::Outside;
}
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
assert!(!blue(fill_back) && blue(stroke_back));
let fill = fill_back.path_data().unwrap().bounds().unwrap();
// The ring's inner contour: the smaller of its two.
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
}
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
/// opacity applies to the stroke as a whole.
#[test]
fn a_translucent_stroke_is_a_surface_of_its_own() {
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.opacity = opacity;
s.blend = blend;
}
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let art = solid_art(&n).unwrap();
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 2);
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
}
}
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
#[test]
fn a_masked_surface_under_a_colored_light_stays_whole() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
}
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
let art = solid_art(&n).unwrap();
let drawn = art.children().unwrap();
assert_eq!(drawn.len(), surfaces, "{lights}");
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
}
}
#[test]
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
let mut n = square_node();
@@ -230,9 +448,9 @@ fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
let mut n = square_node();
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
// Straight on, the extrusion shows its front: the offset square.
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}");
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
let art = solid_art(&n).unwrap();
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
@@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
}
let (groups, clips, paths) = count(&art);
assert!(paths > 1000, "exercise a tessellated surface");
assert_eq!(clips, 1);
assert_eq!(groups, 7, "group count must not grow with triangle count");
// The fill's solid and the stroke's, each one composite with its own clip.
let surfaces = art.children().unwrap().len();
assert_eq!(surfaces, 2);
assert_eq!(clips, surfaces);
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
}
+1 -1
View File
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
if transparent && flat.is_some() {
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
}
let created = vectorcraft_doc::metadata::now_unix();
let created = vectorcraft_doc::metadata::output_time();
let mut renderer = vectorcraft_render::Renderer::new();
for (board, region, origin) in pages {
let rect = vectorcraft_eps::preview_rect(region, origin);
+1 -1
View File
@@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
if boards.len() > 1 {
enc.warnings.push(SEPARATE.to_string());
}
let created = vectorcraft_doc::metadata::now_unix();
let created = vectorcraft_doc::metadata::output_time();
let failed = vectorcraft_render::decode_failures();
for b in boards {
let one = super::save::artboard_doc(doc, b);
+10 -2
View File
@@ -194,6 +194,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
)
}
/// 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()))
}
/// [`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)) {
@@ -239,9 +245,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
}
// Saved without its PDF part: the editing copy is all the file has.
// 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() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(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))
}
Err(e) => Err(e),
})?
+6 -5
View File
@@ -660,7 +660,7 @@ pub const FORMATS: &[Format] = &[
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
// vector app for a raster editor's documents.
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false),
// Photoshop's large document format: read like a PSD (its merged image), never written.
reader("psb", "PSB", &["psb"], "image/x-psb", true),
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
@@ -702,10 +702,11 @@ pub const OPEN_EXTS: &[&str] = &[
"afpub",
];
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
/// them, and the packaging declares no `.epsf`, `.ps` or Targa type (File › Open and Place still
/// read them all).
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop
/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft
/// for it.
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
+1 -1
View File
@@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::now_unix();
let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
}
+13 -9
View File
@@ -143,13 +143,13 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Re
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
/// layer of what it shows, with the same text choice as Open, and the import notes.
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let opts = ImportOptions {
pages: Some(vec![page]),
crop: o.crop,
password: o.password.clone(),
text_as: TextAs::Outlines,
text_as: o.text_as,
layers: false,
..Default::default()
};
@@ -183,15 +183,22 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages});
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
if let Some(page) = thumbnail_page(p, info.pages.len(), "PDF")? {
let (doc, _) = page_document(&bytes, page, &o)?;
out["thumbnail"] = json!(thumbnail(&doc, p)?);
}
Ok(out)
}
/// The page (0-based) `document.pdfInfo`'s `thumbnail` names, from 1 to `count`; `None` when it is
/// absent or null. Any other value (0, a negative or fractional number, a string) is an error that
/// names the pages the `what` has.
fn thumbnail_page(p: &Value, count: usize, what: &str) -> Result<Option<usize>> {
let Some(v) = p.get("thumbnail").filter(|v| !v.is_null()) else { return Ok(None) };
let page = v.as_u64().and_then(|n| usize::try_from(n).ok()).and_then(|n| n.checked_sub(1)).filter(|i| *i < count);
page.map(Some).ok_or_else(|| bad(INFO, format!("thumbnail: no page {v} (the {what} has {count})")))
}
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
@@ -200,10 +207,7 @@ fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
let page = vectorcraft_eps::page(bytes).map_err(err)?;
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
if n != 1 {
return Err(bad(INFO, format!("thumbnail: no page {n} (the file has 1)")));
}
if thumbnail_page(p, 1, "file")?.is_some() {
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
out["thumbnail"] = json!(thumbnail(&doc, p)?);
}
+1 -1
View File
@@ -387,7 +387,7 @@ impl<'a> Naming<'a> {
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
@@ -229,6 +229,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
for pages in [json!("1"), json!(1), json!("1-")] {
let mut s = Session::new();
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
check_sample(&s);
}
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).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";
@@ -38,6 +38,64 @@ fn text_opens_as_type_or_outlines() {
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
}
fn live_text_pdf() -> Vec<u8> {
let page = |text: &str| PdfPage {
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
..PdfPage::new(200.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td ({text}) Tj ET"))
};
pdf(&[page("First page"), page("Second page")], None)
}
fn live_texts(s: &Session) -> Vec<String> {
let mut texts = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
texts
}
#[test]
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
let bytes = live_text_pdf();
let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
let path = dir.join("text.pdf");
std::fs::write(&path, &bytes).unwrap();
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let mut p = source;
p["page"] = json!(2);
let r = s.execute("file.place", &p).unwrap();
assert_eq!(live_texts(&s), ["Second page"], "{r}");
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn pdf_clipboard_paste_keeps_live_text_on_the_selected_page() {
let bytes = live_text_pdf();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "page": 2})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(live_texts(&s), ["Second page"], "{r}");
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
}
#[test]
fn pdf_place_honors_explicit_text_outlines() {
let bytes = live_text_pdf();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let r =
s.execute("file.place", &json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "textAs": "outlines"})).unwrap();
assert!(live_texts(&s).is_empty());
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
}
#[test]
fn layers_option_keeps_or_flattens_optional_content() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
@@ -228,6 +228,27 @@ fn pdf_info_reads_postscript_as_one_page() {
assert!(e.contains("no page 2"), "{e}");
}
/// `document.pdfInfo`'s `thumbnail` is a page number from 1, for a PDF and for PostScript alike:
/// any other value is an error that names the pages, and an absent or null one gives no thumbnail.
#[test]
fn pdf_info_refuses_a_thumbnail_that_is_not_a_page_number() {
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
let mut s = Session::new();
for (bytes, pages) in [(&three_pages()[..], "(the PDF has 3)"), (ps, "(the file has 1)")] {
for n in [json!(-1), json!(0), json!(1.5), json!("1"), json!(true)] {
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": n})).unwrap_err().to_string();
assert!(e.contains(&format!("thumbnail: no page {n} {pages}")), "{n}: {e}");
}
for none in [json!({"dataBase64": b64(bytes)}), json!({"dataBase64": b64(bytes), "thumbnail": null})] {
assert!(s.execute("document.pdfInfo", &none).unwrap().get("thumbnail").is_none(), "{pages}");
}
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 64})).unwrap();
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!(img.width().max(img.height()), 64, "{pages}");
}
}
/// A page showing only text, with an editor's private data when `private`.
fn text_page(private: bool, also: &str) -> PdfPage {
PdfPage {
+3 -1
View File
@@ -47,6 +47,7 @@ pub(crate) mod menucmds;
pub(crate) mod newart;
pub mod newdoc;
mod object;
pub(crate) mod objects_on_path;
mod opacitymask;
mod overprint;
mod package;
@@ -257,6 +258,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(brushsym::specs());
v.extend(patterncmds::specs());
v.extend(intertwine::specs());
v.extend(objects_on_path::specs());
v.extend(prefscmds::specs());
v.extend(distortcmds::specs());
v.extend(paint::proxy_specs());
@@ -500,7 +502,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Optio
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time);
s.note_journal(key, serde_json::json!(t));
t
}
+334
View File
@@ -0,0 +1,334 @@
//! Object › Objects on Path (#2545): objects attached to a path and spread along it.
//!
//! `object.objectsOnPath.attach` puts the selected objects and a path in an Objects on Path group:
//! a group with a [`ObjectsOnPath`] record ([`vectorcraft_doc::objects_on_path`]), the path at its
//! bottom. Called without a path (Object › Objects on Path › Attach) it chooses the Objects on Path
//! tool, whose click on a path attaches the selected objects to that path. The objects go along the
//! path column by column from left to right, top to bottom in each column, spread evenly from its
//! first anchor and turned to follow it. `object.objectsOnPath.options` sets the group's Pivot and
//! Rotate, and `object.objectsOnPath.expand` makes it a plain group.
//!
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
//! objects added to or taken out of the group join the end of the path or leave the record, and
//! art that replaces an object at its index (Break Link to Symbol) keeps its place.
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html> (the tool and
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
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::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::edit::{roots_of, selected_roots};
use super::*;
/// The id of the Objects on Path tool.
pub const TOOL: &str = "objectsOnPath";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.objectsOnPath.attach",
"Attach",
["Object", "Objects on Path"],
None,
"{path?: id of the path to attach to (without it: choose the Objects on Path tool, whose click on a path attaches the selection to it), ids?: the objects to attach (default: the selection; the path is left out of it), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; default 4, the center), rotate?: degrees counter-clockwise from upright on the path (default 0)} put the objects and the path in an Objects on Path group: the path at the bottom, the objects spread evenly along it from its first anchor (the last one at the end of an open path) in their order on the canvas, column by column from left to right and top to bottom in each column, each turned to follow the path; editing the path moves them → {id}",
has_selection,
attach
),
cmd!(
"object.objectsOnPath.options",
"Objects on Path Options",
[],
None,
"{ids?: Objects on Path groups or objects in them (default: the selection), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; 4: the center), rotate?: degrees counter-clockwise from upright on the path} set how the objects of the groups attach → {ids}",
has_objects_on_path,
options
),
cmd!(
"object.objectsOnPath.expand",
"Expand",
["Object", "Objects on Path"],
None,
"{ids?: Objects on Path groups or objects in them (default: the selection)} make the groups plain groups of the path and the objects where they are → {ids}",
has_objects_on_path,
expand
),
]
}
// ---------- predicates ----------
/// The Objects on Path groups of `ids`: the ones among them, and the innermost one around each
/// object in one.
fn groups_of(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for id in ids {
let group = d.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| d.node(*a).is_some_and(is_objects_on_path));
if let Some(g) = group
&& !out.contains(&g)
{
out.push(g);
}
}
out
}
/// The Objects on Path groups of the selection ([`groups_of`]).
fn selected_groups(s: &Session) -> Vec<NodeId> {
s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
}
/// The groups a command acts on: those of `ids` when given, otherwise those of the selection.
fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_none_or(Value::is_null) {
return Ok(selected_groups(s));
}
let ids = checked_ids_param(s, p, "ids", cmd)?;
let groups = groups_of(&s.doc()?.doc, &ids);
if groups.is_empty() {
return Err(bad(cmd, "`ids` must name Objects on Path groups or objects in them"));
}
Ok(groups)
}
fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) }
}
/// The Objects on Path group around `id` (not `id` itself), if any.
fn group_around(d: &Document, id: NodeId) -> Option<NodeId> {
d.ancestry(id).unwrap_or_default().into_iter().filter(|a| *a != id).find(|a| d.node(*a).is_some_and(is_objects_on_path))
}
// ---------- Attach ----------
/// `pivot` and `rotate` from `p`, each checked.
fn pivot_rotate(p: &Value, cmd: &str, pivot: u8, rotate: f64) -> Result<(u8, f64)> {
check_numbers(p, &["pivot", "rotate"], cmd)?;
let pivot = match p.get("pivot").filter(|v| !v.is_null()) {
None => pivot,
Some(v) => v.as_u64().filter(|n| *n <= 8).map(|n| n as u8).ok_or_else(|| bad(cmd, "`pivot` must be a whole number from 0 to 8"))?,
};
let rotate = match p.get("rotate").and_then(Value::as_f64) {
None => rotate,
Some(r) if r.is_finite() => r,
Some(_) => return Err(bad(cmd, "`rotate` must be a finite number")),
};
Ok((pivot, rotate))
}
/// `ids` in their order along the path: column by column from left to right, top to bottom in each
/// column. A column is a run of objects, taken by their left edges, that each start left of the
/// right edge of the column so far. Objects without bounds go last. Symbol instances are measured
/// by their art.
fn path_order(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let symbols = d.symbol_boxes();
let mut placed: Vec<(NodeId, Rect)> = vec![];
let mut rest = vec![];
for id in ids {
match d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)) {
Some(b) => placed.push((*id, b)),
None => rest.push(*id),
}
}
placed.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
let mut columns: Vec<(f64, Vec<(NodeId, Rect)>)> = vec![];
for (id, b) in placed {
match columns.last_mut() {
Some((right, column)) if b.x0 < *right => {
*right = right.max(b.x1);
column.push((id, b));
}
_ => columns.push((b.x1, vec![(id, b)])),
}
}
let mut out = vec![];
for (_, mut column) in columns {
column.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
out.extend(column.into_iter().map(|(id, _)| id));
}
out.extend(rest);
out
}
/// Why the path `path` can't take objects, if it can't.
fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
let n = d.node(path)?;
if attachable_path(n).is_none() {
return Some("pick a path to attach the objects to (not a guide, a compound path or type)");
}
if group_around(d, path).is_some() {
return Some("the path is already in an Objects on Path group");
}
None
}
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
if n.is_layer() {
return Some("layers can't be attached to a path: select the objects in them");
}
if group_around(d, n.id).is_some() {
return Some("an object already on a path can't be attached again");
}
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")
}
fn attach(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.attach";
let Some(path) = p.get("path").filter(|v| !v.is_null()) else {
let view = s.last_view;
s.select_tool(TOOL, view)?;
return Ok(json!({ "tool": TOOL }));
};
let path = checked_id(s, path, C)?;
let (pivot, rotate) = pivot_rotate(p, C, CENTER, 0.0)?;
let roots = match p.get("ids") {
Some(_) => roots_of(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?),
None => selected_roots(s)?,
};
let ids: Vec<NodeId> = roots.into_iter().filter(|id| *id != path).collect();
let order = {
let d = &s.doc()?.doc;
if let Some(why) = path_refused(d, path) {
return Err(bad(C, why));
}
if ids.is_empty() {
return Err(bad(C, "select the objects to attach to the path"));
}
for id in &ids {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if d.ancestry(path).unwrap_or_default().contains(id) {
return Err(bad(C, "the path can't be inside one of the objects to attach"));
}
if let Some(why) = object_refused(d, n) {
return Err(bad(C, why));
}
}
path_order(d, &ids)
};
let id = s.edit("Attach Objects on Path", |d, sel| {
let members: Vec<Node> = order.iter().filter_map(|id| d.node(*id).cloned()).collect();
let rec = ObjectsOnPath::new(path, &members.iter().collect::<Vec<_>>(), pivot, rotate);
let mut wrapped = ids.clone();
wrapped.push(path);
let wrapped = d.paint_order(wrapped);
let id = d.alloc_id();
d.wrap(&wrapped, |mut children| {
// The path at the bottom: the objects are drawn over it.
if let Some(i) = children.iter().position(|c| c.id == path) {
let p = children.remove(i);
children.insert(0, p);
}
let mut g = Node::new(id, NodeKind::Group { children, clip: false });
g.extras_mut().objects_on_path = Some(Box::new(rec));
g
})?;
place_in(d, id);
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
// ---------- Options ----------
fn options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.options";
let groups = target_groups(s, p, C)?;
pivot_rotate(p, C, CENTER, 0.0)?;
s.edit("Objects on Path Options", |d, _| {
for g in &groups {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
let Some(rec) = n.extras().objects_on_path.as_deref().cloned() else { continue };
let (pivot, rotate) = pivot_rotate(p, C, rec.pivot, rec.rotate)?;
n.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath { pivot, rotate, ..rec }));
}
Ok(())
})?;
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let groups = target_groups(s, p, "object.objectsOnPath.expand")?;
s.edit("Expand Objects on Path", |d, sel| {
for g in &groups {
d.node_mut(*g).ok_or(EngineError::NoNode(*g))?.extras_mut().objects_on_path = None;
}
sel.set(groups.iter().copied());
Ok(())
})?;
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- refresh ----------
/// After an edit: put the objects of every Objects on Path group that changed back on its path
/// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched.
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
let mut groups = vec![];
changed_groups(&before.layers, &doc.layers, &mut groups);
for id in groups.iter().rev() {
follow_replacements(before, doc, *id);
place_in(doc, *id);
}
}
/// Members of the group `id` that the edit replaced with new art at the same index keep their
/// place on the path ([`ObjectsOnPath::follow_replacements`]).
fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) {
let (Some(old), Some(new)) = (before.node(id), doc.node(id)) else { return };
let (Some(old), Some(new)) = (old.children(), new.children()) else { return };
let Some(mut rec) = doc.node(id).and_then(|n| n.extras().objects_on_path.as_deref().cloned()) else { return };
let unchanged = rec.clone();
rec.follow_replacements(old, new);
if rec != unchanged
&& let Some(n) = doc.node_mut(id)
{
n.extras_mut().objects_on_path = Some(Box::new(rec));
}
}
/// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art.
fn place_in(doc: &mut Document, id: NodeId) {
let Some(n) = doc.node(id) else { return };
let mut g = n.clone();
if place(&mut g, &doc.symbol_boxes())
&& let Some(slot) = doc.node_mut(id)
{
*slot = g;
}
}
/// The Objects on Path groups among `after` and inside them that are not the same nodes as before
/// (`before`: the same list before the edit), outer ones first.
fn changed_groups(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
let mut by_id: Option<HashMap<NodeId, &Arc<Node>>> = None;
for (i, a) in after.iter().enumerate() {
let b = match before.get(i) {
Some(b) if b.id == a.id => Some(b),
_ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(),
};
if b.is_some_and(|b| Arc::ptr_eq(b, a)) {
continue;
}
if is_objects_on_path(a) {
out.push(a.id);
}
if let Some(children) = a.children() {
changed_groups(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
}
}
}
+1 -1
View File
@@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
fn dos_time() -> (u16, u16) {
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) };
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
+26 -2
View File
@@ -42,6 +42,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_handle
),
cmd!(
"path.moveAnchor",
"Move Anchor Point",
[],
None,
"{id, subpath?, anchor, x, y} move one anchor to (x, y), its handles with it",
has_doc,
move_anchor
),
cmd!(
"path.setAnchors",
"Set Anchors",
@@ -230,6 +239,22 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
/// tool's Shift-drag).
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 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)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
*a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p));
Ok(())
})?;
ok()
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
@@ -602,7 +627,6 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
if gone.is_empty() {
return vec![sp];
}
let keep = |anchors: Vec<Anchor>| (anchors.len() >= 2).then(|| SubPath::new(anchors, false));
let mut runs: Vec<Vec<Anchor>> = vec![];
let mut run = vec![];
for (i, anchor) in sp.anchors.into_iter().enumerate() {
@@ -630,7 +654,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
} else if !run.is_empty() {
runs.push(run);
}
runs.into_iter().filter_map(keep).collect()
runs.into_iter().filter_map(open_run).collect()
}
/// Remove connections, splitting open/closed contours into their surviving open runs.
+12 -4
View File
@@ -459,7 +459,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
const C: &str = "dataset.select";
let (applied, skipped, mut typed) = s.edit("Apply Data Set", |d, sel| {
let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| {
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
// One object may be driven by several variables; walk them in definition order, so two
// text variables on the same object land the later one's value and both are counted.
@@ -467,6 +467,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
for v in &d.variables.variables {
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
}
// Whether an object is bound to a variable that this row has a value for (the reply's `warning`).
let bound = pairs.iter().any(|(_, var)| ds.values.contains_key(var));
let mut applied = 0usize;
let mut skipped = 0usize;
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
@@ -514,12 +516,18 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
d.variables.active_dataset = Some(name.to_string());
// Every selected object left hidden or locked, such as one the row hid, is deselected.
sel.deselect_uneditable(d);
Ok((applied, skipped, typed))
Ok((applied, skipped, typed, bound))
})?;
// Two text variables on one object rewrite it twice; it is named once.
typed.sort_unstable();
typed.dedup();
Ok(super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped})))
let mut out = super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped}));
// A row with no value for any bound variable applies nothing. The reply then carries a
// `warning`, which the app shows in its status bar.
if !bound {
out["warning"] = json!(format!("nothing in data set {name} was applied: no object is bound to a variable it has a value for"));
}
Ok(out)
}
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1037,7 +1045,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data Set",
["Window", "Variables"],
None,
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits)}",
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits), warning? (when no object is bound to a variable that the dataset has a value for, so nothing was applied)}",
has_doc,
dataset_select
),
+6 -1
View File
@@ -1702,14 +1702,17 @@ impl Session {
let before = st.doc.clone();
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
// Dimensions follow the art they measure.
// Objects attached to a path follow it, and dimensions follow the art they measure.
let result = match f(doc, &mut st.selection).and_then(|v| {
cmd::objects_on_path::refresh(&before, doc);
cmd::dimension::refresh(&before, doc)?;
Ok(v)
}) {
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Attached Shaper groups whose art was rebuilt go back on their path.
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
// Inline graphics in text follow their symbols.
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
// Text Wrap: area type follows its wrap objects; then threads re-flow.
@@ -2185,6 +2188,8 @@ mod tests_newart;
#[cfg(test)]
mod tests_newdoc;
#[cfg(test)]
mod tests_objects_on_path;
#[cfg(test)]
mod tests_objexpand;
#[cfg(test)]
mod tests_opacitymask;
+38
View File
@@ -983,6 +983,24 @@ fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
}
#[test]
fn move_anchor_takes_its_handles_along_and_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 0, "x": 140, "y": 250});
refuses_an_index_that_is_not_whole(&mut s, "path.moveAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.moveAnchor", &p, id);
// The anchor lands on (x, y) and its handles keep their offsets from it.
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 30, "y": 40})).unwrap();
let a = path(&s, id).subpaths[0].anchors[0];
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 0, "x": a.p.x + 15.0, "y": a.p.y - 5.0})).unwrap();
let b = path(&s, id).subpaths[0].anchors[0];
assert_eq!(b.p, a.p + vectorcraft_geom::Vec2::new(15.0, -5.0));
assert_eq!(b.h_out - b.p, a.h_out - a.p);
assert_eq!(b.h_in - b.p, a.h_in - a.p);
assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 99, "x": 0, "y": 0})).is_err());
}
#[test]
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
@@ -1968,6 +1986,26 @@ fn open_anchor_delete_preserves_curves_appearance_and_history() {
assert_eq!(s.doc().unwrap().doc, after);
}
#[test]
fn closed_anchor_delete_then_join_closes_with_a_line() {
let mut s = session();
let data = "M100 100C120 60 180 60 200 100C220 140 280 140 300 100C320 60 380 60 400 100C420 140 480 140 500 100C500 200 100 200 100 100Z";
let id = NodeId(s.execute("path.create", &json!({"d": data})).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]]})).unwrap();
s.execute("path.deleteAnchors", &json!({})).unwrap();
let result = path(&s, id);
assert_eq!(result.subpaths.len(), 1);
let sp = &result.subpaths[0];
assert!(!sp.closed);
assert!(!sp.anchors.first().unwrap().has_in() && !sp.anchors.last().unwrap().has_out(), "dangling handles are cleared");
s.execute("path.join", &json!({"ids": [id.0]})).unwrap();
let joined = path(&s, id);
let sp = &joined.subpaths[0];
assert!(sp.closed);
let closing = sp.segment(sp.segment_count() - 1);
assert!(closing.p1 == closing.p0 && closing.p2 == closing.p3, "closing segment is a line: {closing:?}");
}
#[test]
fn open_anchor_delete_does_not_change_explicit_remove_anchors() {
for command in ["path.removeAnchors", "path.removeAnchor"] {
+2 -2
View File
@@ -175,9 +175,9 @@ fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified:
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
let before = vectorcraft_doc::metadata::now_unix().unwrap();
let before = vectorcraft_doc::metadata::output_time().unwrap();
let out = f();
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
(out, before..=vectorcraft_doc::metadata::output_time().unwrap())
}
#[test]
+17
View File
@@ -160,3 +160,20 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() {
let v2 = json_of(&saved(&mut s, json!({"version": 2})));
assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1");
}
/// A save records the date the document was made in, so the same commands twice gave different
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
#[test]
fn a_pinned_date_makes_saves_byte_identical() {
let once = |t: i64| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
saved(&mut s, json!({}))
};
let at = 1_791_208_980;
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
// The date really is in the bytes, so the comparison above is not vacuous.
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
}
+390
View File
@@ -0,0 +1,390 @@
//! Object › Objects on Path (#2545): objects attached to a path, spread along it and turned to
//! follow it, kept there while the path is edited. Driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use super::tests_clip::{id_of, select};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
id_of(&s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn center(s: &Session, id: NodeId) -> Point {
let c = bounds(s, id).center();
Point::new((c.x * 1000.0).round() / 1000.0, (c.y * 1000.0).round() / 1000.0)
}
fn attach(s: &mut Session, path: NodeId, objects: &[NodeId], extra: Value) -> NodeId {
select(s, objects);
let mut p = json!({ "path": path.0 });
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
id_of(&s.execute("object.objectsOnPath.attach", &p).unwrap())
}
fn set_line(s: &mut Session, id: NodeId, a: (f64, f64), b: (f64, f64)) {
let anchors = json!([{"x": a.0, "y": a.1}, {"x": b.0, "y": b.1}]);
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap();
}
/// Three 20 pt squares, out of order on the canvas, and a 300 pt line from (50, 300) to the right.
fn squares_and_line(s: &mut Session) -> ([NodeId; 3], NodeId) {
let right = rect(s, 250.0, 20.0, 20.0, 20.0);
let left = rect(s, 10.0, 100.0, 20.0, 20.0);
let middle = rect(s, 120.0, 0.0, 20.0, 20.0);
let path = line(s, (50.0, 300.0), (350.0, 300.0));
([left, middle, right], path)
}
#[test]
fn attach_spreads_the_objects_evenly_along_an_open_path_from_left_to_right() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[right, left, middle], json!({}));
let d = &s.doc().unwrap().doc;
let n = d.node(g).unwrap();
assert_eq!(n.kind_label(), "Objects on Path");
assert_eq!(n.children().unwrap().first().map(|c| c.id), Some(path), "the path is at the bottom of the group");
let rec = n.extras().objects_on_path.as_deref().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, middle, right], "left to right on the canvas");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
// First on the start, last on the end, evenly between: the centers on the line.
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0));
let b = bounds(&s, middle);
assert!((b.width() - 20.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9, "upright on a level path: {b:?}");
}
#[test]
fn objects_in_a_column_go_top_to_bottom() {
let mut s = session();
// Its left edge is the leftmost, but it is below the other one in their column.
let lower = rect(&mut s, 8.0, 80.0, 20.0, 20.0);
let upper = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let beside = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (0.0, 300.0), (300.0, 300.0));
let g = attach(&mut s, path, &[beside, lower, upper], json!({}));
let ids: Vec<NodeId> = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
assert_eq!(ids, [upper, lower, beside]);
}
#[test]
fn the_objects_turn_with_the_path_and_rotate_tilts_them() {
let mut s = session();
// A 40 × 10 bar on a path that runs straight down: it turns a quarter.
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (200.0, 50.0), (200.0, 350.0));
let g = attach(&mut s, path, &[bar, other], json!({}));
let b = bounds(&s, bar);
assert!((b.width() - 10.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "across the path: {b:?}");
assert_eq!(center(&s, bar), Point::new(200.0, 50.0));
// Rotate 90: turned back level, still centered on the path.
s.execute("object.objectsOnPath.options", &json!({"rotate": 90})).unwrap();
let b = bounds(&s, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "tilted by Rotate: {b:?}");
assert_eq!(center(&s, other), Point::new(200.0, 350.0));
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!((rec.pivot, rec.rotate), (4, 90.0));
// The same angle given to Attach.
let mut t = session();
let bar = rect(&mut t, 0.0, 0.0, 40.0, 10.0);
let path = line(&mut t, (200.0, 50.0), (200.0, 350.0));
attach(&mut t, path, &[bar], json!({"rotate": 90}));
let b = bounds(&t, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_turned_object_attaches_square_to_the_path_or_across_it() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
s.execute("object.rotate", &json!({"ids": [a.0], "angle": 30})).unwrap();
s.execute("object.rotate", &json!({"ids": [b.0], "angle": 60})).unwrap();
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[a, b], json!({}));
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert!((ba.width() - 40.0).abs() < 1e-6 && (ba.height() - 10.0).abs() < 1e-6, "30° rounds to level: {ba:?}");
assert!((bb.width() - 10.0).abs() < 1e-6 && (bb.height() - 40.0).abs() < 1e-6, "60° rounds to upright: {bb:?}");
}
#[test]
fn a_reflected_group_keeps_the_mirrored_tilt() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 30}));
// The x of the bar's topmost corner, from its center: tilted counter-clockwise, it is on the right.
let top_x = |s: &Session| {
let n = s.doc().unwrap().doc.node(bar).unwrap();
let anchors = &n.path_data().unwrap().subpaths[0].anchors;
let top = anchors.iter().min_by(|a, b| a.p.y.total_cmp(&b.p.y)).unwrap().p;
top.x - bounds(s, bar).center().x
};
assert!(top_x(&s) > 1.0);
select(&mut s, &[g]);
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(top_x(&s) < -1.0, "mirrored: tilted clockwise");
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.rotate, -30.0);
}
#[test]
fn scaling_the_group_out_of_proportion_keeps_the_scaled_tilt_and_scaling_back_restores_it() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 45}));
let anchors = |s: &Session| -> Vec<(f64, f64)> {
let n = s.doc().unwrap().doc.node(bar).unwrap().clone();
n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
};
let before = anchors(&s);
select(&mut s, &[g]);
s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [200, 300]})).unwrap();
// The bar's long edge was at 45°: twice as wide, it slopes at atan(1/2).
let a = anchors(&s);
let (dx, dy) = (a[1].0 - a[0].0, a[1].1 - a[0].1);
assert!((dy.abs().atan2(dx.abs()).to_degrees() - 0.5f64.atan().to_degrees()).abs() < 1e-6, "{a:?}");
s.execute("object.scale", &json!({"sx": 50, "sy": 100, "origin": [200, 300]})).unwrap();
for (p, q) in anchors(&s).iter().zip(&before) {
assert!((p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6, "{:?} against {before:?}", anchors(&s));
}
}
#[test]
fn pivot_picks_the_point_that_sits_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({"pivot": 7}));
// The middle of each square's bottom edge is on the line: the squares stand on it.
let b = bounds(&s, middle);
assert!((b.y1 - 300.0).abs() < 1e-6 && (b.center().x - 200.0).abs() < 1e-6, "{b:?}");
s.execute("object.objectsOnPath.options", &json!({"pivot": 1})).unwrap();
let b = bounds(&s, middle);
assert!((b.y0 - 300.0).abs() < 1e-6, "hanging from the line by their top edge: {b:?}");
}
#[test]
fn a_closed_path_spreads_the_objects_all_the_way_round_from_its_first_anchor() {
let mut s = session();
let ids: Vec<NodeId> = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect();
let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let first = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().subpaths[0].anchors[0].p;
attach(&mut s, ring, &ids, json!({}));
assert_eq!(center(&s, ids[0]), first, "the first object on the first anchor");
let mut at: Vec<(i64, i64)> = ids.iter().map(|id| center(&s, *id)).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect();
at.sort();
assert_eq!(at, [(100, 100), (100, 300), (300, 100), (300, 300)], "a quarter of the way round apart");
}
#[test]
fn editing_the_path_moves_the_objects_and_undo_takes_it_back() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({}));
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, left), Point::new(50.0, 250.0));
assert_eq!(center(&s, middle), Point::new(150.0, 250.0));
assert_eq!(center(&s, right), Point::new(250.0, 250.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "one undo step puts the path and the objects back");
// Attach itself is one step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(130.0, 10.0));
let d = &s.doc().unwrap().doc;
assert!(d.layers.iter().all(|l| l.children().unwrap().iter().all(|c| !vectorcraft_doc::objects_on_path::is_objects_on_path(c))));
}
#[test]
fn an_object_moved_on_its_own_goes_back_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("object.move", &json!({"ids": [middle.0], "dx": 30, "dy": -40})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
// The whole group moves as one.
select(&mut s, &[g]);
s.execute("object.move", &json!({"dx": 10, "dy": 20})).unwrap();
assert_eq!(center(&s, middle), Point::new(210.0, 320.0));
assert_eq!(center(&s, right), Point::new(360.0, 320.0));
}
#[test]
fn objects_moved_into_the_group_join_the_end_and_ones_moved_out_leave() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle], json!({}));
s.execute("layer.move", &json!({"ids": [right.0], "target": g.0, "place": "inside"})).unwrap();
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "the new object goes at the end");
s.execute("layer.move", &json!({"ids": [middle.0], "target": g.0, "place": "above"})).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, right]);
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "two objects stay on the ends");
}
#[test]
fn art_that_replaces_an_object_keeps_its_place_on_the_path() {
let mut s = session();
let mark = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let first = id_of(&s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark.0]})).unwrap());
let second = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 100, "y": 10})).unwrap());
let third = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 200, "y": 10})).unwrap());
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[first, second, third], json!({}));
let v = s.execute("symbol.breakLink", &json!({"ids": [first.0]})).unwrap();
let art = v["ids"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(NodeId).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [art, second, third], "the art takes the instance's place");
assert_eq!(center(&s, art), Point::new(50.0, 300.0));
assert_eq!(center(&s, third), Point::new(350.0, 300.0));
}
#[test]
fn a_shaper_group_stays_on_the_path_when_a_source_changes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
rect(&mut s, 50.0, 0.0, 100.0, 100.0);
let pts = [[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]];
let shaper = NodeId(s.execute("shaper.scribble", &json!({"points": pts})).unwrap()["id"].as_u64().unwrap());
let other = rect(&mut s, 300.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[shaper, other], json!({}));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
select(&mut s, &[a]);
s.execute("object.setBounds", &json!({"width": 200.0, "height": 100.0, "reference": 0})).unwrap();
// The rebuilt art is wider, and its center is still on the start of the path.
assert!(bounds(&s, shaper).width() > 180.0, "{:?}", bounds(&s, shaper));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
}
#[test]
fn the_group_saves_and_reopens_live_and_exports_as_plain_art() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30, "pivot": 7}));
let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap();
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.clone();
assert_eq!(o.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path, before);
set_line(&mut o, path, (50.0, 200.0), (350.0, 200.0));
assert!((bounds(&o, middle).center().x - bounds(&s, middle).center().x).abs() < 1e-6);
assert!(bounds(&o, middle).center().y < 210.0, "editing the reopened path moves the objects");
for format in ["svg", "pdf"] {
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": format!("objects.{format}"), "dataBase64": data})).unwrap();
let r = vectorcraft_render::Renderer::new().render(&o.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
let c = bounds(&s, middle).center();
assert!(r.pixel(c.x as u32, c.y as u32 - 4)[3] > 128, "{format}: the square is drawn where it is attached");
assert!(r.pixel(130, 10)[3] < 10, "{format}: not where it was drawn");
}
}
#[test]
fn a_pasted_copy_stays_live_on_its_own_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
select(&mut s, &[g]);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.to_vec();
let [copy] = copy[..] else { panic!("{copy:?}") };
assert_ne!(copy, g);
let n = s.doc().unwrap().doc.node(copy).unwrap().clone();
let rec = n.extras().objects_on_path.as_deref().cloned().unwrap();
let kids: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
assert_eq!(kids.first(), Some(&rec.path), "the copy names its own path");
assert!(rec.members.iter().all(|m| kids.contains(&m.id) && ![left, middle, right].contains(&m.id)));
let (a, b) = (s.doc().unwrap().doc.node(rec.path).unwrap().geometric_bounds().unwrap(), bounds(&s, path));
set_line(&mut s, rec.path, (a.x0, a.y0 - 50.0), (a.x1, a.y0 - 50.0));
assert!((bounds(&s, rec.members[1].id).center().y - (a.y0 - 50.0)).abs() < 1e-6, "the copy's objects follow the copy's path");
assert_eq!(bounds(&s, path), b);
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the original stays");
}
#[test]
fn expand_leaves_a_plain_group() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
let v = s.execute("object.objectsOnPath.expand", &json!({})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
assert_eq!(n.kind_label(), "Group");
assert_eq!(n.children().unwrap().len(), 4);
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the objects stay where they were");
assert!(s.execute("object.objectsOnPath.expand", &json!({})).is_err(), "nothing left to expand");
}
#[test]
fn attach_without_a_path_chooses_the_tool() {
let mut s = session();
let ([left, ..], _) = squares_and_line(&mut s);
select(&mut s, &[left]);
assert_eq!(s.execute("object.objectsOnPath.attach", &json!({})).unwrap()["tool"], "objectsOnPath");
assert_eq!(s.tool_id(), "objectsOnPath");
}
#[test]
fn what_cannot_be_attached_is_refused_with_a_reason() {
let mut s = session();
let ([left, _, right], path) = squares_and_line(&mut s);
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string();
select(&mut s, &[path]);
assert!(err(&mut s, json!({"path": path.0})).contains("select the objects"), "the path alone");
let dot = line(&mut s, (10.0, 10.0), (10.0, 10.0));
assert!(err(&mut s, json!({"path": dot.0, "ids": [left.0]})).contains("pick a path"), "a path without length");
let guide = line(&mut s, (0.0, 380.0), (300.0, 380.0));
select(&mut s, &[guide]);
s.execute("view.guides.make", &json!({})).unwrap();
assert!(err(&mut s, json!({"path": guide.0, "ids": [right.0]})).contains("pick a path"), "a guide");
let g = attach(&mut s, path, &[right], json!({}));
let other = line(&mut s, (0.0, 350.0), (300.0, 350.0));
assert!(err(&mut s, json!({"path": other.0, "ids": [right.0]})).contains("already on a path"));
assert!(err(&mut s, json!({"path": path.0, "ids": [left.0]})).contains("already in an Objects on Path group"));
assert!(err(&mut s, json!({"path": other.0, "ids": [g.0], "pivot": 9})).contains("pivot"));
assert!(s.execute("object.objectsOnPath.options", &json!({"ids": [other.0]})).is_err());
}
#[test]
fn options_and_expand_take_ids_without_a_selection() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("select.set", &json!({"ids": []})).unwrap();
// An object in the group names the group.
let v = s.execute("object.objectsOnPath.options", &json!({"ids": [middle.0], "pivot": 7})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert!((bounds(&s, middle).y1 - 300.0).abs() < 1e-6, "standing on the line");
let v = s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert_eq!(s.doc().unwrap().doc.node(g).unwrap().kind_label(), "Group");
assert!(s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).is_err(), "no longer an Objects on Path group");
}
+38
View File
@@ -131,6 +131,44 @@ fn mismatched_and_missing_bindings_are_skipped_and_counted() {
assert_eq!(plain(&s, t), "y");
}
/// A row with no value for any bound variable, such as one from Import Data Sets… before anything
/// is bound, applies nothing, and the reply's `warning` names it. A bound variable with a value in
/// the row gives no warning, even when its object is locked and skipped.
#[test]
fn a_row_with_no_value_for_a_bound_variable_warns_that_nothing_was_applied() {
let mut s = session();
let t = text(&mut s, "x");
s.execute("dataset.import", &json!({"csv": "name,Name,Title\nA,Ann,Boss\nB,Bob,Chief\n"})).unwrap();
let warning = |row: &str| format!("nothing in data set {row} was applied: no object is bound to a variable it has a value for");
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(0), &json!(warning("A"))), "{v}");
let v = s.execute("dataset.next", &json!({})).unwrap();
assert_eq!(v["warning"], json!(warning("B")), "{v}");
let v = s.execute("dataset.prev", &json!({})).unwrap();
assert_eq!(v["warning"], json!(warning("A")), "{v}");
assert_eq!(plain(&s, t), "x");
// A bound variable that the row has no value for still applies nothing.
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Other", "ids": [t.0]})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(1), &json!(warning("B"))), "{v}");
// Bound to a variable with a value, the row applies it and gives no warning.
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
assert_eq!(v["applied"].as_u64(), Some(1), "{v}");
assert!(v.get("warning").is_none(), "{v}");
assert_eq!(plain(&s, t), "Boss");
// Locked art is skipped, and its binding still counts as bound.
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
assert_eq!(v["applied"].as_u64(), Some(0), "{v}");
assert!(v.get("warning").is_none(), "{v}");
}
#[test]
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
let mut s = session();
+37
View File
@@ -227,6 +227,43 @@ fn live_objects_draw_through_new_color_settings() {
}
}
/// The art drawn for 3D effects and color adjustments follows the Color Settings: in the next
/// frame, a CMYK cyan rectangle with Revolve, with Extrude & Bevel, with Inflate, with Rotate, with
/// Brightness/Contrast, and a group holding it with Brightness/Contrast, draw through the new
/// working CMYK profile.
#[test]
fn effect_art_draws_through_new_color_settings() {
let _g = settings();
let old = cms::active_settings();
let cyan = json!({"c": 100, "m": 0, "y": 0, "k": 0});
let brighter = json!({"brightness": 30});
let cases = [
("the revolved rectangle", "threeD.revolve", json!({}), false),
("the extruded rectangle", "threeD.extrude", json!({}), false),
("the inflated rectangle", "threeD.inflate", json!({}), false),
("the rotated rectangle", "threeD.rotate", json!({}), false),
("the adjusted rectangle", "adjust.brightnessContrast", brighter.clone(), false),
("the adjusted group", "adjust.brightnessContrast", brighter, true),
];
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &opts).pixels;
for (what, effect, params, grouped) in cases {
let mut s = session();
let shape = rect(&mut s, 150.0, 60.0, 60.0, 120.0);
exec(&mut s, "paint.setFill", json!({"ids": [shape.0], "color": cyan}));
let target = if grouped { id_of(&exec(&mut s, "object.group", json!({"ids": [shape.0]}))) } else { shape };
exec(&mut s, "effect.apply", json!({"ids": [target.0], "effect": effect, "params": params}));
let mut r = Renderer::new();
let before = frame(&mut r, &s);
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let fresh = frame(&mut Renderer::new(), &s);
let warm = frame(&mut r, &s);
cms::set_active(&old).unwrap();
assert!(fresh != before, "{what}: the new profile changes the frame");
assert!(warm == fresh, "{what}: drawn as a new renderer draws it");
}
}
/// #2276: with a working RGB space other than sRGB, the Eyedropper takes an image's sRGB pixel
/// into the working space, so the sampled fill shows as the pixel it was picked from.
#[test]
+2
View File
@@ -219,6 +219,8 @@ fn structured_junk() {
("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})),
("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})),
("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})),
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 100, "x": 0, "y": 0})),
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 0, "x": 1e308, "y": -1e308})),
("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})),
("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})),
("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})),
+4
View File
@@ -1324,6 +1324,10 @@ impl<'a> Reader<'a> {
// Far-Eastern type) and final-form positions (§15.3).
"Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg"
| "Tu" | "Xu" => {}
// Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th`
// hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe`
// tab descriptions between them).
"Th" | "XL" | "Xb" | "XB" | "Xe" => {}
"TE" => self.te(&vals),
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
"XI" if self.raster.is_some() => {
@@ -121,6 +121,23 @@ fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
}
/// A `.ai` in PostScript form (read through its layers) whose text carries hyphenation and
/// tab-ruler attributes: `XL` (hyphenation language), `Xb`/`XB` (a tab definition) and `Th`
/// (hyphen control). These keep no geometry, so the reader drops them rather than failing the
/// file the way it did before — the symptom seen on Illustrator 6/8 EPS files whose
/// `Adobe_level2_AI5` program can't be run.
#[test]
fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() {
let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 9 Tf\n\
%_ 0 XL\n36 0 Xb\nXB\n0 1 2 2 99 Th\n0 Ta\n(hi) Tx\nTO\n";
// The operators sit on a visible layer (so they'd be reported as unknown, not quietly skipped
// on a hidden one), beside the square so the layer is never empty.
let src = NAMED_PROLOG.replace("S\nLB\n%AI5_EndLayer--\n%AI5_BeginLayer", &format!("S\n{text}LB\n%AI5_EndLayer--\n%AI5_BeginLayer"));
let r = import(src.as_bytes()).unwrap();
assert!(!r.preview, "read through the layers, not a fallback preview");
assert!(!r.warnings.iter().any(|w| w.contains("doesn't know") || w.contains("couldn't be read")), "{:?}", r.warnings);
}
/// An Illustrator 3 file of paths alone, as image editors export their paths (storytold/photocraft#6036):
/// no prolog and no layers, the paths unpainted (`n` closes, `N` doesn't), a compound path of two.
const PATHS_ONLY: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: an image editor\n%%BoundingBox: 0 0 200 100\n\
+22
View File
@@ -28,9 +28,11 @@ pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
// Convex: every turn goes the same way (straight runs allowed).
let n = pts.len();
let mut sign = 0.0f64;
let mut turning = 0.0f64;
for i in 0..n {
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
let cross = (b - a).cross(c - b);
turning += cross.atan2((b - a).dot(c - b));
if cross.abs() <= 1e-9 {
continue;
}
@@ -40,6 +42,10 @@ pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
return false;
}
}
// A simple convex polygon turns through exactly one full revolution; a star winds more.
if (turning.abs() - std::f64::consts::TAU).abs() > 1e-6 {
return false;
}
let inner = r.inflate(-tol, -tol);
sign != 0.0
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
@@ -244,4 +250,20 @@ mod regress_tests {
let worst = (0..=200).map(|i| distance_to_outline(&bp, c.eval(i as f64 / 200.0))).fold(0.0, f64::max);
assert!(worst < 1e-6, "{worst}");
}
#[test]
fn polygon_covers_rect_rejects_self_intersecting_star() {
let mut b = BezPath::new();
for k in 0..5 {
let a = (-90.0 + 144.0 * k as f64).to_radians();
let p = Point::new(100.0 * a.cos(), 100.0 * a.sin());
if k == 0 {
b.move_to(p);
} else {
b.line_to(p);
}
}
b.close_path();
assert!(!polygon_covers_rect(&b, Rect::new(-55.0, -20.0, 55.0, 78.0), 0.01));
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::now_unix();
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
crate::pdfx::require_date(set.standard, created_at)?;
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
+8 -1
View File
@@ -149,6 +149,10 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it,
/// Warning: glyphs were kept as outlines rather than read as type.
pub const OUTLINED_TEXT_NOTE: &str = "text was converted to outlines";
/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) was left
/// out: VectorCraft has no type that is kept but not drawn, so a PDF saved from it loses that
/// searchable text (#2675).
pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) was left out";
/// Warning: glyphs that differ from the installed font of their name were kept as outlines.
pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines";
/// The end of the warning that glyphs whose characters the file doesn't name were kept as
@@ -1963,7 +1967,10 @@ impl<'a> Device<'a> for Builder<'_> {
) {
self.pending = false;
let stroke = match draw_mode {
GlyphDrawMode::Invisible => return,
GlyphDrawMode::Invisible => {
self.warn(INVISIBLE_TEXT_NOTE);
return;
}
GlyphDrawMode::Fill => None,
GlyphDrawMode::Stroke(p) => Some(p.clone()),
};
+2 -2
View File
@@ -74,8 +74,8 @@ pub use encrypt::Encryption;
pub use export::{export, export_with_report, page_areas};
pub use file_info::{FileInfo, file_info};
pub use import::{
OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, import,
import_with_report, is_glyph_outlines, is_outlines_note,
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
import, import_with_report, is_glyph_outlines, is_outlines_note,
};
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
+1 -1
View File
@@ -355,7 +355,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
let set = &opts.settings;
let job = Job::new(doc, set, opts.postscript)?;
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
let created = opts.created.or_else(vectorcraft_doc::metadata::output_time);
let sep = Separator::new(&job.doc, set);
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
let marks = set.marks.printer_marks();
+2 -2
View File
@@ -53,9 +53,9 @@ fn the_native_document_comes_back_intact() {
fn the_native_documents_modification_date_is_the_time_of_writing() {
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
let before = year(vectorcraft_doc::metadata::now_unix());
let before = year(vectorcraft_doc::metadata::output_time());
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
let after = year(vectorcraft_doc::metadata::now_unix());
let after = year(vectorcraft_doc::metadata::output_time());
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
}
+1 -1
View File
@@ -229,7 +229,7 @@ fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
});
out
};
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
let now = || vectorcraft_doc::metadata::output_time().unwrap();
// Both print the minute they are written in: try again when the minute turns between them.
for _ in 0..3 {
let minute = now() / 60;
+33
View File
@@ -177,3 +177,36 @@ pub(crate) fn transfer_contract() {
fn soft_mask_inversion_uses_quantized_transfer_endpoints() {
transfer_contract();
}
/// #2675: text in rendering mode 3 (invisible, as a scanned page's OCR layer) isn't kept, and the
/// import now says so, for type and outlines alike. Visible text beside it still comes in, and a
/// page without invisible text gets no such warning.
#[test]
fn invisible_text_is_reported_when_left_out() {
let open = |content: &str, text_as: TextAs| import_with_report(&text_pdf(content), &ImportOptions { text_as, ..Default::default() }).unwrap();
let invisible = "BT /F1 20 Tf 3 Tr 1 0 0 1 10 40 Tm (AB) Tj 0 Tr 1 0 0 1 10 10 Tm (CA) Tj ET";
for text_as in [TextAs::Text, TextAs::Outlines] {
let r = open(invisible, text_as);
assert!(r.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", r.warnings);
// What came in: the type's text, and where the art lies. On the 60 pt page the visible
// line (baseline 10 pt up) lies 35–50 pt down, the invisible one (40 pt up) 5–20 pt down.
let (mut text, mut tops) = (String::new(), vec![]);
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
text.push_str(&t.plain_text());
}
if matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. } | NodeKind::Compound { .. })
&& let Some(b) = n.visual_bounds()
{
tops.push(b.y0);
}
});
if text_as == TextAs::Text {
assert!(text.contains("CA") && !text.contains("AB"), "{text_as:?}: {text:?}");
}
assert!(!tops.is_empty(), "{text_as:?}: the visible line still comes in");
assert!(tops.iter().all(|y| *y > 25.0), "{text_as:?}: only the visible line's art, tops at {tops:?}");
let visible = open("BT /F1 20 Tf 1 0 0 1 10 10 Tm (CA) Tj ET", text_as);
assert!(!visible.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", visible.warnings);
}
}
+194 -34
View File
@@ -108,6 +108,23 @@ fn evaluates_live(n: &Node) -> bool {
is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n))
}
/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than
/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed),
/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color
/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of
/// the objects in it.
fn reads_outside(n: &Node) -> bool {
let mut reads = false;
n.walk(&mut |c| {
reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
|| effects::has_revolve(c)
|| effects::has_solid(c)
|| effects::has_adjustment(c)
|| c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art))
});
reads
}
/// What [`Renderer::raster_fx`] paints effects around.
struct Content<'a> {
/// The object the effects belong to (cached shadows are checked against it).
@@ -620,6 +637,11 @@ pub(crate) struct PixelEntry {
rel: (f64, f64),
k: f64,
image: std::sync::Arc<vello_cpu::Pixmap>,
/// For a raster of the content's whole reach at full size, `None`: it draws after a pan too.
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
/// or any blur zoomed in, reaches past the pixel budget).
view: Option<ViewKey>,
made: Made,
/// What the content read when the raster was made, with the channels the effects ran on and,
/// under De-Interlace, the raster resolution.
@@ -648,6 +670,15 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a
/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws
/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]).
type ViewKey = ([u64; 6], [u64; 4]);
fn view_key(view: Affine, screen: Rect) -> ViewKey {
(view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits))
}
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster
/// kept from an earlier frame instead (#2558).
@@ -732,6 +763,10 @@ pub(crate) struct ShadowEntry {
image: std::sync::Arc<vello_cpu::Pixmap>,
/// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget.
scale: f64,
/// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one
/// cropped to the view, or made over such a raster, the view and the rect it was made for: it
/// draws only there ([`PixelEntry::view`]).
view: Option<ViewKey>,
made: Made,
/// What the silhouette read when the raster was made.
inputs: Inputs,
@@ -749,6 +784,8 @@ struct Shadow {
mode: vectorcraft_doc::color::BlendMode,
/// Made before its object moved ([`ShadowEntry::moved`]).
moved: bool,
/// Cropped to the view, or made over a raster that is ([`ShadowEntry::view`]).
cropped: bool,
}
impl ShadowEntry {
@@ -1012,8 +1049,13 @@ impl Renderer {
/// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU,
/// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are
/// unchanged (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). `None` when the effect isn't a shadow/glow or the raster would be
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
/// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to
/// what can show in the `screen` (device pixels): the view, and around it the blur's spread and
/// `around` (document units: how far the shadows and glows stacked over this one look), made
/// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one
/// made over it (`over_cropped`), is reused only in the same view. `None` when the effect isn't
/// a shadow/glow or the raster would still be too large (caller falls back to
/// [`Self::draw_shadow_filter`]).
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
#[allow(clippy::too_many_arguments)]
@@ -1025,6 +1067,7 @@ impl Renderer {
fx: &RasterFx,
reach: Rect,
owner: Option<&Arc<Node>>,
(screen, around, over_cropped): (Rect, f64, bool),
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> Option<Shadow> {
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
@@ -1039,37 +1082,54 @@ impl Renderer {
// What the silhouette reads: as the raster kept for `n` found it, else found now.
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
let inputs = self.raster_inputs(f, n, reads);
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved);
let in_view = view_key(f.view, screen);
let exact = |e: &ShadowEntry| {
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view)
};
if interacting && !self.shadows.get(&key).is_some_and(exact) {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
}
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
let (image, x, y, scale, moved) = match hit {
let (image, x, y, scale, moved, cropped) = match hit {
Some(e) => {
e.stamp = stamp;
self.stats.moved += usize::from(e.moved);
let p = f.view * e.anchor;
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.is_some())
}
None => {
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
let reach = reach + Vec2::new(dx, dy);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread);
// The raster's region and scale within `budget` pixels: `None` when over the limits.
let region = |r: Rect, coarser: bool, budget: f64| {
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 };
if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale))
};
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
// layer the caller falls back to can't make its silhouette solid.
let scale = if matches!(fx, RasterFx::OuterGlow { .. }) { (w * h / 4.0e6).sqrt().ceil().max(1.0) } else { 1.0 };
if scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
return None;
}
let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| !over_cropped);
// Else it is cropped to what can show in the view, within the budget of the filter
// layer it would otherwise be drawn through, and made coarser in the same way.
let ((x0, y0, w, h, scale), cropped) = match full {
Some(r) => (r, false),
None => {
let m = spread + around / f.px;
let r = whole.intersect(screen.inflate(m, m));
if r.width() <= 0.0 || r.height() <= 0.0 {
return None;
}
(region(r, true, PIXEL_BUDGET)?, true)
}
};
// During a drag, a larger shadow on a multithreaded context is filtered within the
// drag's budget (`with_filters`), and no raster is kept.
if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET {
@@ -1128,12 +1188,13 @@ impl Renderer {
let p = f.view * anchor;
let node = Drawn::of(n, owner);
let rel = (x0 - p.x, y0 - p.y);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, made, inputs, moved, stamp });
let view = cropped.then_some(in_view);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0, scale, moved)
(image, x0, y0, scale, moved, cropped)
}
};
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved })
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped })
}
/// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx`
@@ -1224,6 +1285,7 @@ impl Renderer {
(k.1, k.2) == (key.1, key.2)
&& e.stamp < stamp
&& e.linear == linear
&& e.view.is_none()
&& e.node.id() == n.id
&& e.inputs == *inputs
&& e.made.fits(&[fx], size, None, None)
@@ -1358,8 +1420,9 @@ impl Renderer {
/// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their
/// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]);
/// without the object's transparency and effects (a path keeps its geometry effects). Art that
/// is made from live objects it evaluated ([`evaluates_live`]) is kept only while the inputs of
/// their evaluation stay the same ([`Self::live_found`]).
/// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or
/// colors shaded through the color settings ([`reads_outside`]), is kept only while what it
/// reads from outside `a` stays the same ([`Self::live_found`]).
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize;
let mut kept = None;
@@ -1372,7 +1435,7 @@ impl Renderer {
Some(was) => kept = Some((art.clone(), was.0)),
}
}
let found = (kept.is_some() || (cache && evaluates_live(a))).then(|| self.live_found(doc, a));
let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a));
if let Some((art, was)) = kept
&& found.as_ref().is_some_and(|f| f.0 == was)
{
@@ -1565,7 +1628,17 @@ impl Renderer {
// through a filter layer from the content alone, and goes into the later silhouettes that way.
let mut made: Vec<(&RasterFx, Option<Shadow>)> = vec![];
let mut stacked = reach;
let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));
// How far (document units) a shadow or glow looks from a pixel to the silhouette under it.
let looks = |fx: &RasterFx| match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(),
RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5,
_ => 0.0,
};
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
// A raster cropped to the view covers what the shadows and glows stacked over it read.
let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum();
let over_cropped = made.iter().any(|(_, s)| s.as_ref().is_some_and(|s| s.cropped));
let shadow = {
let earlier = &made;
let view = f.view;
@@ -1582,7 +1655,8 @@ impl Renderer {
}
paint(r, c, fr);
};
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), &mut art))
let view = (screen, around, over_cropped);
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art))
};
stacked = match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)),
@@ -1652,7 +1726,10 @@ impl Renderer {
/// every effect only looks nearby, and content still over either limit is rendered at a lower
/// resolution. A whole raster at full resolution is cached while the object, the
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
/// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or
/// lower-resolution raster is cached while the object, the view (its pan included) and what the
/// content reads stay the same ([`PixelEntry::view`]); a lower-resolution one is kept resampled to
/// the view's pixels ([`Self::resampled`]).
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
/// pixels and is not cached.
fn pixel_content(
@@ -1702,9 +1779,11 @@ impl Renderer {
let side = f64::from(crate::MAX_RASTER_SIDE);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
// The cached raster, when the whole content was rendered at full size at this zoom.
// The cached raster: of the whole content at full size at this zoom, else of this view.
let linear = linear_key(f.view);
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
let whole = fits && k >= 1.0;
let in_view = (!whole).then(|| view_key(f.view, screen));
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
let stamp = self.stamp;
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
@@ -1722,8 +1801,11 @@ impl Renderer {
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
});
let interacting = f.opts.interacting;
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved);
let exact = |e: &PixelEntry| {
e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
};
if interacting
&& whole
&& let Some(key) = key
&& let Some(inputs) = &inputs
&& !self.pixel_fx.get(&key).is_some_and(exact)
@@ -1797,9 +1879,10 @@ impl Renderer {
let failed = crate::paint::decode_failures() - decoded;
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
// What the raster is made from, for its object once it moves (`move_pixel_content`).
// What the raster is made from, for its object once it moves (`move_pixel_content`): a
// cropped or coarser raster never moves.
let made = key.map(|_| Made {
pixels: pm.data_as_u8_slice().to_vec(),
pixels: if whole { pm.data_as_u8_slice().to_vec() } else { Vec::new() },
fx: chain.iter().map(|fx| (*fx).clone()).collect(),
size: content.reach.size(),
channels: Some(channels),
@@ -1845,6 +1928,14 @@ impl Renderer {
}
}
let image = std::sync::Arc::new(pm);
// A raster kept for this view alone at a lower resolution than the view's is resampled to the
// view's pixels once: each frame that draws it then copies it pixel for pixel, where sampling
// it again across the whole view cost several times as much as a plain fill.
let view_raster = key.is_some() && in_view.is_some() && k < 1.0 && content.kept.is_none();
let (image, x0, y0, k) = match view_raster.then(|| self.resampled(f, image.clone(), (x0, y0), k, screen)).flatten() {
Some((image, x, y)) => (image, x, y, 1.0),
None => (image, x0, y0, k),
};
// A cancelled export drew part of the content: that raster isn't kept for later frames.
if !vectorcraft_doc::cancel::is_cancelled()
&& let Some(((key, made), inputs)) = key.zip(made).zip(inputs)
@@ -1854,7 +1945,8 @@ impl Renderer {
let rel = (x0 - p.x, y0 - p.y);
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
let node = Drawn::of(content.node, owner.as_ref());
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp });
let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp };
self.pixel_fx.insert(key, entry);
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
// An image over the limits was drawn at a frame's resolution: its raster is kept.
@@ -1868,6 +1960,33 @@ impl Renderer {
}
}
/// `image` as [`draw_scaled`] draws it with its top-left at (`x`, `y`) device pixels, `1 / k`
/// device pixels a pixel, resampled to the device pixels it covers of `screen`, with their
/// top-left. `None` when it covers none of them.
fn resampled(
&mut self,
f: &Frame,
image: std::sync::Arc<vello_cpu::Pixmap>,
(x, y): (f64, f64),
k: f64,
screen: Rect,
) -> Option<(std::sync::Arc<vello_cpu::Pixmap>, f64, f64)> {
let covered = Rect::new(x, y, x + f64::from(image.width()) / k, y + f64::from(image.height()) / k).intersect(screen);
let (x0, y0) = (covered.x0.floor(), covered.y0.floor());
let (w, h) = (covered.x1.ceil() - x0, covered.y1.ceil() - y0);
let side = f64::from(crate::MAX_RASTER_SIDE);
if !((1.0..=side).contains(&w) && (1.0..=side).contains(&h)) {
return None;
}
let (w, h) = (w as u16, h as u16);
let mut off = f.offscreen_context(w, h);
draw_scaled(&mut off, image, (x - x0, y - y0), k);
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
Some((std::sync::Arc::new(pm), x0, y0))
}
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
/// made at full size for its object (the same id, slot and ink, at this zoom and rotation, with
/// the same `inputs`) when only the content's place changed after that frame: made by the same
@@ -1898,6 +2017,7 @@ impl Renderer {
&& e.stamp < stamp
&& e.linear == linear
&& e.k >= 1.0
&& e.view.is_none()
&& e.node.id() == content.node.id
&& e.inputs == *inputs
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
@@ -2372,6 +2492,45 @@ mod tests {
assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike");
}
/// An Outer Glow whose whole reach is over the budget is made coarser over that whole reach and
/// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole
/// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes,
/// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel
/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and
/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow
/// round the page at 800% (scale 3, near the narrowest blur made coarser).
#[test]
fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
let size = (640, 360);
for (rect, blur, zoom, center, scale) in [
(Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0),
(Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0),
] {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut n =
Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0));
n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }];
d.insert(Some(layer), 0, n).unwrap();
let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, base, &opts);
let image = |r: &Renderer| r.shadows.values().map(|e| std::sync::Arc::as_ptr(&e.image)).collect::<Vec<_>>();
let kept = image(&r);
assert!(r.shadows.values().all(|e| e.scale == scale && e.view.is_none()), "blur {blur}: coarser, over the whole reach");
for pan in [(37.0, -21.0), (1.0, 0.0), (-3.0, 5.0), (0.5, 0.0), (37.3, -21.6)] {
let xf = Affine::translate(pan) * base;
let panned = r.render(&d, size.0, size.1, xf, &opts);
assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn");
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
}
}
}
/// The multithreaded rasterizer draws the same pixels with any number of workers, so the
/// automatic thread count can follow the machine.
#[test]
@@ -2854,8 +3013,9 @@ mod tests {
/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a
/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so no raster is
/// kept for either, and a 1600 × 1000 view near its corner needs more than the drag's budget. The
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so the still
/// frame keeps rasters cropped to its view, which the moved rectangle can't take along, and a
/// 1600 × 1000 view near its corner needs more than the drag's budget. The
/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame
/// rendered while interacting without a drag (a dialog's preview).
#[test]
@@ -2885,7 +3045,7 @@ mod tests {
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content");
assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again");
assert!(r.pixel_fx.is_empty() && r.shadows.is_empty(), "no raster kept");
assert!(r.pixel_fx.is_empty() && r.shadows.values().all(|e| e.stamp < r.stamp), "no raster kept");
assert!(dragged != fresh, "at the drag's lower resolution");
let after = r.render(&d, 1600, 1000, view, &still).pixels;
assert_eq!((r.stats.previewed, r.stats.moved), (0, 0));
+5 -2
View File
@@ -410,8 +410,9 @@ type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that
/// art can be made from evaluated live objects (`fx::evaluates_live`), the inputs of their
/// evaluation ([`live::Found`]).
/// art can be made from evaluated live objects (`fx::evaluates_live`) or from type, symbol art or
/// colors shaded through the color settings (`fx::reads_outside`), the inputs it was made with
/// ([`live::Found`]).
type FxArtEntry = (Arc<Node>, Arc<Node>, Option<live::Found>);
/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits
/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]).
@@ -2527,3 +2528,5 @@ mod tests_setup;
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;
#[cfg(test)]
mod tests_viewraster;
+3 -3
View File
@@ -68,8 +68,8 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
crate::effects::expand_live(None, n)
}
/// What the evaluation of `n` (a live node, or an object whose effect art can be made from
/// evaluated live objects, see [`Renderer::live_found`]) reads from the document, placed files,
/// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with
/// its inputs, see [`Renderer::live_found`]) reads from the document, placed files,
/// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of
/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font
/// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the
@@ -343,7 +343,7 @@ fn spread_to_edges(px: &mut [vello_cpu::color::PremulRgba8], filled: Vec<bool>,
impl Renderer {
/// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a
/// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from
/// evaluated live objects.
/// evaluated live objects, type, symbol art or colors shaded through the color settings.
pub(crate) fn live_found(&mut self, doc: &Document, n: &Node) -> Found {
let inputs = self.doc_inputs(doc);
let (key, reads) = resources_key(doc, inputs.cms, n);
+54
View File
@@ -155,6 +155,60 @@ fn symbol_type_redraws_when_its_font_becomes_available() {
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type that a geometry effect outlines redraws in a font that becomes available: the art drawn for
/// the effects of the type, of a group that holds it and of a symbol instance whose art holds it
/// is made again when the fonts change.
#[test]
fn type_with_geometry_effects_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
use vectorcraft_doc::Effect;
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
let cases = [
("Moving Serif 4", "the type with a Transform effect"),
("Shadow Serif 5", "the type with a Transform effect and a drop shadow"),
("Massed Serif 4", "the group with a Transform effect"),
("Stamps Serif 4", "the symbol instance with a Transform effect"),
("Darker Serif 4", "the type with a Transform effect in Multiply"),
];
for (i, (family, what)) in cases.into_iter().enumerate() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let at = if i == 3 { Point::new(-140.0, 20.0) } else { Point::new(10.0, 80.0) };
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style))));
match i {
0 => object.appearance.effects.push(moved()),
1 => {
object.appearance.effects.push(moved());
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
2 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(moved());
}
3 => {
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) });
object.appearance.effects.push(moved());
}
_ => {
object.appearance.effects.push(moved());
object.blend = vectorcraft_color::BlendMode::Multiply;
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(family)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "{what}: drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
}
}
/// Type that an envelope bends redraws in a font that becomes available, in the envelope and in
/// the art of a symbol whose instance an envelope bends: the envelopes are evaluated again when the
/// fonts change.
+161
View File
@@ -0,0 +1,161 @@
//! Effect rasters cropped to the view, or made at a lower resolution, are kept while the view
//! stays as it is: dragging other art over an object whose effects reach far past the view (a wide
//! blur, or any blur zoomed in) draws its raster from the last frame.
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::{RenderOptions, Renderer};
/// An A4 page filled by a blue rectangle under `effects`, and a small orange square over it
/// (returned with the document): the square is the art a drag moves.
fn backdrop(effects: &[(&str, serde_json::Value)]) -> (Document, NodeId) {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)),
Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0),
);
back.appearance.effects = effects.iter().map(|(id, p)| Effect { id: (*id).into(), params: p.clone(), visible: true }).collect();
d.insert(Some(layer), 0, back).unwrap();
let id = d.alloc_id();
let square = Node::path(
id,
shapes::rectangle(Rect::new(280.0, 400.0, 300.0, 420.0)),
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0),
);
d.insert(Some(layer), 1, square).unwrap();
(d, id)
}
/// A `w` × `h` view at `zoom`, centered on the page.
fn view(zoom: f64, (w, h): (u32, u32)) -> Affine {
Affine::translate((f64::from(w) / 2.0, f64::from(h) / 2.0)) * Affine::scale(zoom) * Affine::translate((-297.5, -421.0))
}
/// `base` with the square moved by `step` steps of a drag.
fn moved(base: &Document, id: NodeId, step: u32, zoom: f64) -> Document {
let mut d = base.clone();
let by = (f64::from(step) * 3.0 / zoom, f64::from(step) * 2.0 / zoom);
d.node_mut(id).unwrap().transform(Affine::translate(by), false);
d
}
/// A case's name, its effects (id and params) and its zoom.
type Case = (&'static str, Vec<(&'static str, serde_json::Value)>, f64);
/// Every effect whose raster can be cropped to the view or made coarser, with a zoom at which it
/// is: wide blurs at the fit zoom, narrow ones zoomed in, a Photoshop-style filter, a shadow and a
/// glow.
fn cases() -> Vec<Case> {
vec![
("Gaussian Blur 500 pt", vec![("blur.gaussian", json!({"radius": 500}))], 1.6),
("Gaussian Blur 100 pt", vec![("blur.gaussian", json!({"radius": 100}))], 1.6),
("Gaussian Blur 20 pt", vec![("blur.gaussian", json!({"radius": 20}))], 4.0),
("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))], 16.0),
("Feather 20 pt", vec![("stylize.feather", json!({"radius": 20}))], 4.0),
("Craquelure", vec![("texture.craquelure", json!({}))], 4.0),
("Drop Shadow blur 100 pt", vec![("stylize.dropShadow", json!({"blur": 100, "x": 20, "y": 20}))], 1.6),
("Drop Shadow blur 10 pt", vec![("stylize.dropShadow", json!({"blur": 10, "x": 5, "y": 5}))], 8.0),
("Outer Glow blur 100 pt", vec![("stylize.outerGlow", json!({"blur": 100}))], 1.6),
]
}
/// A drag of the square over the backdrop draws the backdrop's effects from the rasters of the
/// frame before the drag, at full resolution: no frame of the drag previews them at a drag's lower
/// resolution, and each draws as a fresh renderer draws its still frame.
#[test]
fn a_drag_over_art_with_effects_past_the_view_draws_their_rasters_kept() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&base, size.0, size.1, xf, &opts);
for step in 1..4 {
let d = moved(&base, id, step, zoom);
let drag = r.render(&d, size.0, size.1, xf, &live);
assert_eq!(r.stats.previewed, 0, "{name}: drag frame {step} drew the backdrop's effects from the rasters kept");
let mut fresh = Renderer::new();
fresh.threads = 2;
let still = fresh.render(&d, size.0, size.1, xf, &opts);
assert!(drag.pixels == still.pixels, "{name}: drag frame {step} draws as a still frame");
}
}
}
/// A pan by whole pixels or a zoom makes the rasters cropped to the view again: the frame after it draws as a
/// fresh renderer draws it.
#[test]
fn rasters_cropped_to_the_view_follow_a_pan_and_a_zoom() {
let size = (640, 360);
for (name, effects, zoom) in cases() {
let (d, _) = backdrop(&effects);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, view(zoom, size), &opts);
for xf in [Affine::translate((37.0, -21.0)) * view(zoom, size), view(zoom * 1.25, size)] {
let image = r.render(&d, size.0, size.1, xf, &opts);
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(image.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "{name}");
}
}
}
/// Time a drag of the square over each backdrop in a 2560 × 1440 view: the first frame renders as
/// before a drag, the next eight as a drag's frames (`interacting` and `dragging`, as the canvas
/// renders them), then the frame after the drag. The hashes of that frame and of a fresh renderer's
/// frame compare builds.
/// Run with `cargo test --release -p vectorcraft-render backdrop_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn backdrop_drag_perf() {
let hash = |px: &[u8]| {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
px.hash(&mut h);
h.finish()
};
let size = (2560, 1440);
for (name, effects, zoom) in cases() {
let (base, id) = backdrop(&effects);
let xf = view(zoom, size);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, dragging: true, ..opts.clone() };
let mut r = Renderer::new();
r.threads = crate::default_threads();
let start = std::time::Instant::now();
std::hint::black_box(r.render(&base, size.0, size.1, xf, &opts));
let first = start.elapsed().as_secs_f64() * 1000.0;
let (mut ms, mut previewed, mut doc) = (vec![], 0, base.clone());
for step in 1..9 {
doc = moved(&base, id, step, zoom);
let start = std::time::Instant::now();
std::hint::black_box(r.render(&doc, size.0, size.1, xf, &live));
ms.push(start.elapsed().as_secs_f64() * 1000.0);
previewed += r.stats.previewed;
}
let start = std::time::Instant::now();
let still = r.render(&doc, size.0, size.1, xf, &opts);
let after = start.elapsed().as_secs_f64() * 1000.0;
let fresh = Renderer::new().render(&doc, size.0, size.1, xf, &opts);
ms.sort_by(f64::total_cmp);
println!(
"{name} at {:.0}%: first frame {first:.1} ms; drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}",
zoom * 100.0,
(ms[3] + ms[4]) * 0.5,
ms[0],
ms[7],
hash(&still.pixels),
hash(&fresh.pixels)
);
}
}
+20
View File
@@ -484,6 +484,23 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
/// Going right, the caret takes them in increasing order on left-to-right glyphs and in
/// decreasing order on right-to-left glyphs; going left, it takes them in the reverse order.
pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize {
caret_horizontal_filtered(layout, byte, right, |_| true)
}
/// Like [`caret_horizontal`], but exclude glyph stops and line edges inside a grapheme of `text`.
pub fn caret_horizontal_cluster(layout: &TextLayout, text: &str, byte: usize, right: bool) -> usize {
use unicode_segmentation::GraphemeCursor;
let valid = |b| text.is_char_boundary(b) && GraphemeCursor::new(b, text.len(), true).is_boundary(text, 0).unwrap_or(false);
let to = caret_horizontal_filtered(layout, byte, right, valid);
if to == byte || valid(to) {
return to;
}
// Wrapping can split a grapheme; normalize only the fallback in its logical direction.
let Some(line) = layout.lines.get(layout.line_of(byte)) else { return byte };
if right != line.rtl { edit::next_cluster(text, to) } else { edit::prev_cluster(text, to) }
}
fn caret_horizontal_filtered(layout: &TextLayout, byte: usize, right: bool, valid: impl Fn(usize) -> bool) -> usize {
let li = layout.line_of(byte);
let Some(line) = layout.lines.get(li) else { return byte };
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default();
@@ -500,6 +517,7 @@ pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize
stops.dedup();
stops
.into_iter()
.filter(|&b| valid(b))
.filter_map(|b| {
// Both the distance and the place among stops at one x are counted in the step's direction.
let (bx, bo) = stop(b);
@@ -557,6 +575,8 @@ mod tests_fit;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_joiners;
#[cfg(test)]
mod tests_justify;
#[cfg(test)]
mod tests_scripts;
+3 -2
View File
@@ -392,7 +392,8 @@ pub(crate) fn shape_range(
let next_script = shaping_script(c);
let strong_script = !matches!(next_script, Script::Common | Script::Inherited);
let script_change = strong_script && script != Script::Common && script != next_script;
// Combining marks stay with their base.
// Combining marks, zero width joiners and non-joiners, and variation selectors stay
// with the character before them.
if (face.id() != seg.face.id() || small != seg.small || level != seg.level || script_change) && !is_mark(c) {
if i > seg.range.start {
seg.range.end = i;
@@ -580,7 +581,7 @@ struct Shaped {
fn is_mark(c: char) -> bool {
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|| matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
}
/// Shape `seg` and append its glyphs to `out`. Each glyph takes the tracking, kerning, baseline
+15 -6
View File
@@ -97,21 +97,30 @@ fn pre_base_matra_is_drawn_before_its_consonant() {
#[test]
fn syllables_map_to_their_source_bytes_and_caret_stops() {
let Some(face) = devanagari_test_face("syllables_map_to_their_source_bytes_and_caret_stops") else { return };
// हि + न्दी: the second syllable's matra stays with the conjunct it follows.
// हि + न्दी: raw font clusters can subdivide a Unicode editing grapheme.
let s = "हिन्दी";
let l = devanagari_layout(&face, s);
let second = "हि".len();
assert_eq!(source_clusters(&l), vec![(0, second), (second, s.len() - second)]);
let clusters = source_clusters(&l);
assert_eq!(clusters.first(), Some(&(0, second)), "{clusters:?}");
let mut end = 0;
for &(byte, len) in &clusters {
assert_eq!(byte, end, "source clusters must cover input without gaps or overlaps: {clusters:?}");
assert!(len > 0, "empty source cluster: {clusters:?}");
end = byte.checked_add(len).expect("source cluster range overflow");
assert!(s.get(byte..end).is_some(), "source cluster must span valid UTF-8: {clusters:?}");
}
assert_eq!(end, s.len(), "source clusters must cover the whole input: {clusters:?}");
// The first syllable's i matra is drawn before its consonant.
assert_eq!(l.glyphs[1].gid, face.glyph_for('ह'), "{:?}", l.glyphs);
assert!(l.glyphs[0].origin.x < l.glyphs[1].origin.x, "{:?}", l.glyphs);
// Left and Right arrows pass over the same syllables the shaper made.
// Editing arrows follow Unicode graphemes, regardless of raw cluster subdivision.
assert_eq!(edit::next_cluster(s, 0), second);
assert_eq!(edit::next_cluster(s, second), s.len());
assert_eq!(edit::prev_cluster(s, s.len()), second);
assert_eq!(caret_horizontal(&l, 0, true), second);
assert_eq!(caret_horizontal(&l, second, true), s.len());
assert_eq!(caret_horizontal(&l, s.len(), false), second);
assert_eq!(caret_horizontal_cluster(&l, s, 0, true), second);
assert_eq!(caret_horizontal_cluster(&l, s, second, true), s.len());
assert_eq!(caret_horizontal_cluster(&l, s, s.len(), false), second);
assert!((caret_position(&l, second).0.x - cluster_extent(&l, 0).1).abs() < 1e-9);
}
+93
View File
@@ -0,0 +1,93 @@
//! A zero width joiner or non-joiner between characters that the style's font lacks is shaped with
//! those characters, in their fallback face. The Unicode Standard describes both joiners under
//! Cursive Connection and Ligatures
//! (https://www.unicode.org/versions/latest/core-spec/chapter-23/), and Microsoft's OpenType shaping
//! specifications for Devanagari and Bengali include them in a syllable after a halant
//! (https://learn.microsoft.com/en-us/typography/script-development/devanagari,
//! https://learn.microsoft.com/en-us/typography/script-development/bengali).
//! No font is committed: the tests use installed faces and print SKIPPED when none is found.
use super::*;
use kurbo::Point;
use std::sync::Arc;
use vectorcraft_doc::CharStyle;
const ZWNJ: char = '\u{200C}';
const ZWJ: char = '\u{200D}';
/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`.
/// With `tags`, the fallback face's GSUB script list must contain one of those script tags. Which
/// face the fallback gives depends on the faces loaded so far, so the tests compare with that face.
fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc<FontFace>, Arc<FontFace>)> {
let db = FontDb::global();
let opentype = |face: &FontFace| {
use skrifa::raw::TableProvider;
tags.is_empty()
|| face
.skrifa()
.and_then(|f| f.gsub().ok())
.and_then(|gsub| gsub.script_list().ok())
.is_some_and(|list| list.script_records().iter().any(|r| tags.contains(&&r.script_tag().to_be_bytes())))
};
let faces = ["Source Sans 3", "Helvetica", "Arial", "Times New Roman", "Liberation Sans", "DejaVu Sans"]
.into_iter()
.filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name))
.filter(|primary| primary.covers(ZWNJ) && primary.covers(ZWJ) && !primary.covers(c))
.find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| opentype(f)).map(|fallback| (primary, fallback)));
if let Some((primary, fallback)) = &faces {
let path = fallback.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string());
eprintln!("ASSERTIONS RUN: {test}; {} {} with the fallback {} {} ({path})", primary.family, primary.style, fallback.family, fallback.style);
} else {
eprintln!("SKIPPED: {test}; no installed Latin face maps both joiners and falls back to a face for {c} with the lookups it needs");
}
faces
}
fn layout_in(face: &FontFace, text: &str, tracking: f64) -> TextLayout {
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, tracking, ..CharStyle::default() };
layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st))
}
/// `text` set in `primary` is drawn, spaced and stepped through exactly as `text` set in `fallback`.
fn assert_shaped_as_in_the_fallback(primary: &FontFace, fallback: &FontFace, text: &str) {
for tracking in [0.0, 200.0] {
let (mixed, single) = (layout_in(primary, text, tracking), layout_in(fallback, text, tracking));
// The caret stops where it stops in the fallback face.
assert_eq!(caret_horizontal(&mixed, 0, true), caret_horizontal(&single, 0, true), "{text:?} with tracking {tracking}");
// Tracking adds no extra space around the joiner.
let width = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::<f64>();
assert_eq!(width(&mixed), width(&single), "{text:?} with tracking {tracking}");
let glyphs = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.len, g.origin, g.advance)).collect::<Vec<_>>();
assert_eq!(glyphs(&mixed), glyphs(&single), "{text:?} with tracking {tracking}");
assert!(mixed.glyphs.iter().all(|g| g.font_id == fallback.id()), "{text:?}: {:?}", mixed.glyphs);
}
}
#[test]
fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() {
let test = "a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it";
let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[]) else { return };
// Ka + halant + ZWNJ + Ssa: Ka keeps its explicit halant instead of forming the KaSsa conjunct.
let s = format!("क्{ZWNJ}ष");
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
}
#[test]
fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() {
let test = "a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it";
let Some((primary, fallback)) = joiner_test_faces(test, 'ক', &[]) else { return };
let s = format!("ক্{ZWNJ}ষ");
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
}
#[test]
fn a_zero_width_joiner_in_devanagari_asks_for_a_half_form() {
let test = "a_zero_width_joiner_in_devanagari_asks_for_a_half_form";
let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[b"dev2", b"deva"]) else { return };
// Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa.
let s = format!("क्{ZWJ}ष");
assert_shaped_as_in_the_fallback(&primary, &fallback, &s);
let l = layout_in(&primary, &s, 0.0);
let half = l.glyphs.first().map(|g| g.gid);
assert!(half.is_some_and(|gid| gid != fallback.glyph_for('क') && gid != fallback.glyph_for('\u{094D}')), "no half form: {:?}", l.glyphs);
assert_eq!(l.glyphs.last().map(|g| g.gid), Some(fallback.glyph_for('ष')), "{:?}", l.glyphs);
}
+1 -1
View File
@@ -8,7 +8,7 @@
mod solid;
mod visibility;
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane};
pub use visibility::{VisibleFace, visible_faces};
mod shading;
pub use shading::{LightGradient, RgbGradient, ShadeGradient};
+86 -26
View File
@@ -433,8 +433,7 @@ struct Camera {
origin: Point,
/// The eye's distance in front of the centre; `None` without perspective.
eye: Option<f64>,
/// Deeper than any part of the object: faces drawn first go this far behind it, and faces
/// drawn last go this far in front of it.
/// Deeper than any part of the object: faces drawn first go this far behind it.
far: f64,
light: V3,
}
@@ -487,7 +486,9 @@ enum Order {
/// a piece of a twisted wall at the solid's outline covers the walls behind it.
Twisted,
/// The face is drawn after every other face when it is turned toward the viewer. A flat end of
/// the solid has the whole solid behind its plane, so it covers whatever it overlaps.
/// the solid has the whole solid behind its plane, so it covers whatever it overlaps. Its depth
/// is `f64::MAX`, so the faces drawn last by several solids sorted together keep the order they
/// were added in.
Last,
}
@@ -526,8 +527,8 @@ impl Builder<'_> {
}
let depth = if behind && order != Order::Twisted {
centre.2 - self.camera.far
} else if order == Order::Last {
centre.2 + self.camera.far
} else if order == Order::Last && facing > 0.0 {
f64::MAX
} else {
centre.2
};
@@ -840,12 +841,11 @@ pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude,
}
}
if o.cap {
// A cap turned toward the viewer covers the twisted walls it overlaps, even those nearer
// than its center.
let ends = if o.twist != 0.0 { Order::Last } else { Order::Depth };
// A cap turned toward the viewer covers the walls it overlaps, even those nearer than its
// center: twisted walls, and the walls of a fill inside the solid of its stroke.
for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] {
let Some(s) = s else { continue };
b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), ends)?;
b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?;
}
}
Ok(b.finish())
@@ -859,7 +859,11 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, facets: vec![] };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
b.face(contours, camera.rotate(V3(0.0, 0.0, 1.0)), Order::Depth)?;
// A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so
// it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in.
let up = camera.rotate(V3(0.0, 0.0, 1.0));
let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up };
b.face(contours, n, Order::Last)?;
Ok(b.finish())
}
@@ -1258,39 +1262,94 @@ fn cell_triangles(pieces: &[Vec<Point>]) -> Option<Vec<[Point; 3]>> {
Some(triangles)
}
/// The part of `outline` inside `cell`, as rings (holes running the other way).
fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
/// The part of `outline` (its rings, each with its bounds) inside `cell`, as rings (holes running
/// the other way). Only the rings whose bounds meet the cell's go into the boolean.
fn material(outline: &[(Rect, vectorcraft_geom::SubPath)], cell: Rect) -> Option<Vec<Vec<Point>>> {
let r = PathData::single(vectorcraft_geom::SubPath::polyline(
&[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)],
true,
));
let near = PathData::new(
outline.iter().filter(|(b, _)| b.x0 <= cell.x1 && cell.x0 <= b.x1 && b.y0 <= cell.y1 && cell.y0 <= b.y1).map(|(_, s)| s.clone()).collect(),
);
let rule = FillRule::NonZero;
let part =
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
vectorcraft_pathops::try_boolean(&r, rule, &near, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
.ok()?;
rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok()
}
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let o = options.sanitized();
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
struct Puff {
rings: Vec<Vec<Point>>,
/// The index of the rings' edges.
edges: Edges,
cell: f64,
nx: usize,
ny: usize,
m: Membrane,
/// How high the front rises at its peak.
height: f64,
}
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
if rings.is_empty() {
return Ok(vec![]);
return Ok(None);
}
let size = bounds.width().max(bounds.height());
if size <= 0.0 {
return Ok(vec![]);
return Ok(None);
}
const CELLS: usize = 48;
let cell = size / CELLS as f64;
let (nx, ny) = (((bounds.width() / cell).ceil() as usize).max(1), ((bounds.height() / cell).ceil() as usize).max(1));
let edges = Edges::new(&rings);
let m = Membrane::solve(&rings, &edges, bounds.origin(), cell, nx + 1, ny + 1);
let outline = PathData::new(rings.iter().map(|r| vectorcraft_geom::SubPath::polyline(r, true)).collect());
// A circle of radius r puffs into a half sphere of radius r at 50 % (its membrane peaks at r²/4).
let height = 2.0 * m.peak.sqrt() * o.volume / 50.0;
Ok(Some(Puff { rings, edges, cell, nx, ny, m, height }))
}
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
inflate_together(&[(path, rule)], bounds, options, view).pop().unwrap_or(Ok(vec![]))
}
/// Inflate for several shapes of one object (a fill and the outlines of its strokes): each puffs
/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest
/// puff needs with Perspective. One result for each shape, in order.
pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec<Result<Vec<Facet>, String>> {
let o = options.sanitized();
let puffs: Vec<Result<Option<Puff>, String>> =
shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect();
let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max);
puffs
.into_iter()
.map(|p| match p {
Ok(Some(p)) => inflated(p, bounds, o, view, reach),
Ok(None) => Ok(vec![]),
Err(e) => Err(e),
})
.collect()
}
/// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the
/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together.
fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result<Vec<Facet>, String> {
let Puff { rings, edges, cell, nx, ny, m, height } = p;
let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings
.iter()
.filter_map(|r| {
let b = r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p));
Some((b, vectorcraft_geom::SubPath::polyline(r, true)))
})
.collect();
// When no shape puffs (no volume, or shapes too narrow for the grid), every front (and a back
// that puffs too) lies flat in one plane: its cells are drawn last, as caps are. If one shape
// puffs, a flat front can be behind its puff, so the cells keep their depths.
let flat = reach <= 0.0;
let lift = |p: Point| {
if m.peak <= 0.0 || edges.near(p, 1e-9) {
return 0.0;
@@ -1315,7 +1374,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
if n.dot(n) > 0.5 { n } else { V3(0.0, 0.0, 1.0) }
};
let smooth = view.shade && view.has_colored_lights();
let camera = Camera::new(view, bounds, o.depth + 2.0 * height);
let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach));
// Without its back cap the object is a shell open behind, its inside showing.
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
let half = o.depth / 2.0;
@@ -1464,7 +1523,8 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
let front: Vec<Vec<V3>> =
pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, half + h)).collect()).collect();
let n = newell(&front);
b.face(front, n, Order::Depth)?;
let order = if flat { Order::Last } else { Order::Depth };
b.face(front, n, order)?;
if o.both_sides && o.cap {
// The back winds the same way round as the front, so its outward normal is
// reversed.
@@ -1474,7 +1534,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
.map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(back_turn.apply(*q), -half - h * back_turn.scale)).collect())
.collect();
let n = newell(&back);
b.face(back, V3(-n.0, -n.1, -n.2), Order::Depth)?;
b.face(back, V3(-n.0, -n.1, -n.2), order)?;
}
}
}
@@ -1482,9 +1542,9 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
}
if !o.both_sides && o.cap {
let back: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(back_turn.apply(*p), -half)).collect()).collect();
// The back turned toward the viewer covers the twisted walls it overlaps, even those
// nearer than its center.
b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)), if o.twist != 0.0 { Order::Last } else { Order::Depth })?;
// The back turned toward the viewer covers the walls it overlaps, even those nearer than
// its center: twisted walls, and the walls of a fill inside the solid of its stroke.
b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)), Order::Last)?;
}
if o.depth > 0.0 {
// The sides, cut into slices that follow a twist or taper.
+24
View File
@@ -176,6 +176,15 @@ fn a_plane_turns_about_the_given_centre() {
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
// Its back shows when turned past 90°: a plane is two-sided.
assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
// From either side it is drawn last, so the planes of several parts sorted together keep the
// order they are given in, and it is lit as the side seen.
for (y, seen) in [(30.0, 1.0), (150.0, -1.0)] {
let v = View { rotation_x: -20.0, rotation_y: y, ..front() };
let f = plane(&p, FillRule::EvenOdd, b, v).unwrap();
let camera = Camera::new(v, b, 0.0);
let lit = camera.brightness(camera.rotate(V3(0.0, 0.0, seen)));
assert!(f.len() == 1 && f[0].depth == f64::MAX && close(f[0].brightness, lit, 1e-12), "{y}: {:?}", f[0]);
}
}
#[test]
@@ -661,6 +670,21 @@ fn inflate_twists_tapers_and_opens_its_back() {
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
}
/// A flat front is drawn after every other face only when it is turned toward the viewer: an open
/// Inflate at 0 % volume seen from behind shows the inside of its front, the far side, and the
/// walls nearer the eye are drawn over it.
#[test]
fn an_open_flat_front_seen_from_behind_is_drawn_under_the_walls() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let o = Inflate { depth: 60.0, volume: 0.0, cap: false, ..Inflate::default() };
let ahead = inflate(&p, FillRule::EvenOdd, b, o, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
assert!(ahead.last().is_some_and(|f| f.depth == f64::MAX), "from in front, the front is drawn last");
let behind = inflate(&p, FillRule::EvenOdd, b, o, View { rotation_x: -20.0, rotation_y: 150.0, ..front() }).unwrap();
assert!(behind.iter().all(|f| f.depth < f64::MAX));
let (gap, at) = worst_misorder(&behind, 120);
assert!(gap < 1.0, "{gap} pt behind at {at:?}");
}
/// At 0 % volume an inflated shape is the extruded one: twisted and tapered, their outlines
/// agree from every side (no wall goes missing in either).
#[test]
+145 -16
View File
@@ -628,16 +628,14 @@ fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) ->
LpHover::None
}
fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec<Overlay> {
/// The highlight of `h`, `width` screen pixels wide in `color`.
fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> Vec<Overlay> {
match h {
LpHover::None => vec![],
LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }],
LpHover::Region(i) => cache
.map
.regions
.get(*i)
.map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color: HIGHLIGHT_RED, width: 4.0, dashed: false }])
.unwrap_or_default(),
LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }],
LpHover::Region(i) => {
cache.map.regions.get(*i).map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }]).unwrap_or_default()
}
}
}
@@ -701,7 +699,7 @@ impl Tool for LivePaintBucketTool {
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
lp_overlays(&self.hover, &self.cache)
lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
@@ -711,18 +709,54 @@ impl Tool for LivePaintBucketTool {
// ---------- Live Paint Selection ----------
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge.
/// The Live Paint Selection tool's highlight width (screen pixels) by default.
pub const LP_SELECT_WIDTH: f64 = 4.0;
/// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes.
pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool
/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight
/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select
/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills
/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click
/// selects no face or edge.
pub struct LivePaintSelectionTool {
cache: RegionCache,
hover: LpHover,
fills: bool,
strokes: bool,
color: [u8; 3],
width: f64,
}
impl Default for LivePaintSelectionTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH }
}
}
impl LivePaintSelectionTool {
/// What a click picks, Alt held or not: Some(true) an edge, Some(false) a face, None nothing.
fn picks_edge(&self, alt: bool) -> Option<bool> {
match (self.fills, self.strokes) {
(true, true) => Some(alt),
(true, false) => Some(false),
(false, true) => Some(true),
(false, false) => None,
}
}
}
/// A highlight color option: `#rrggbb` or a layer color's name (any case).
fn lp_color(v: &Value) -> Option<[u8; 3]> {
let s = v.as_str()?.trim();
if let Some((_, c)) = vectorcraft_doc::LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) {
return Some(*c);
}
let [r, g, b, _] = vectorcraft_color::Color::from_hex(s)?.to_rgba8(1.0);
Some([r, g, b])
}
impl Tool for LivePaintSelectionTool {
fn id(&self) -> &'static str {
"livePaintSelection"
@@ -730,18 +764,21 @@ impl Tool for LivePaintSelectionTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let edge = self.picks_edge(ev.mods.alt);
match ev.kind {
PointerKind::Move => {
self.hover = match lp_hover(cx, &mut self.cache, p, ev.mods.alt) {
h @ LpHover::Part(_) => h,
self.hover = match edge.map(|e| lp_hover(cx, &mut self.cache, p, e)) {
Some(h @ LpHover::Part(_)) => h,
_ => LpHover::None,
};
vec![]
}
PointerKind::Down => {
let mut hit = None;
if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) {
let part = if ev.mods.alt { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) };
if let Some(edge) = edge
&& let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id))
{
let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) };
hit = part.map(|n| n.id);
}
match (hit, ev.mods.shift) {
@@ -756,12 +793,36 @@ impl Tool for LivePaintSelectionTool {
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
lp_overlays(&self.hover, &self.cache)
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Arrow
}
fn options(&self) -> Value {
let [r, g, b] = self.color;
json!({
"selectFills": self.fills,
"selectStrokes": self.strokes,
"color": format!("#{r:02x}{g:02x}{b:02x}"),
"width": self.width,
})
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"selectFills" => self.fills = v.as_bool().unwrap_or(self.fills),
"selectStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes),
"color" => self.color = lp_color(v).unwrap_or(self.color),
"width" => {
let (min, max) = LP_SELECT_WIDTH_RANGE;
self.width = v.as_f64().filter(|w| w.is_finite()).map_or(self.width, |w| w.clamp(min, max));
}
_ => return,
}
self.hover = LpHover::None;
}
}
/// Create a builder-family tool by id (None = not one of ours).
@@ -1014,6 +1075,74 @@ mod tests {
assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]);
}
#[test]
fn live_paint_selection_options_choose_what_a_click_selects() {
let (d, _gid, f2, e) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let none = vec![Action::Exec("select.none".into(), json!({}))];
let mut t = LivePaintSelectionTool::default();
assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0}));
// Select Fills off: a click inside a face selects nothing, and a click by an edge selects it.
t.set_option("selectFills", &json!(false));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none);
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e]));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e]));
// Select Strokes off: Alt-click by an edge selects the face under the pointer.
t.set_option("selectFills", &json!(true));
t.set_option("selectStrokes", &json!(false));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2]));
t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt));
let ov = t.overlays(&cx);
let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(50.0, 0.0, 100.0, 50.0), "the face, not the edge");
// Both off: nothing is selected or highlighted.
t.set_option("selectFills", &json!(false));
for m in [Mods::default(), alt] {
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none);
t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m));
assert!(t.overlays(&cx).is_empty());
}
let shift = Mods { shift: true, ..Default::default() };
assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty());
}
#[test]
fn live_paint_selection_options_set_the_highlight() {
let (d, ..) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintSelectionTool::default();
let highlight = |t: &mut LivePaintSelectionTool| {
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
match t.overlays(&cx).as_slice() {
[Overlay::Path { color, width, .. }] => (*color, *width),
ov => panic!("{ov:?}"),
}
};
assert_eq!(highlight(&mut t), (HIGHLIGHT_RED, 4.0));
t.set_option("color", &json!("#00FF00"));
t.set_option("width", &json!(9));
assert_eq!(highlight(&mut t), ([0, 0xff, 0], 9.0));
// A layer color's name; a value that is not a color, or a width that is not a number, is ignored.
t.set_option("color", &json!("light blue"));
t.set_option("width", &json!("wide"));
assert_eq!(highlight(&mut t), ([0x4f, 0x80, 0xff], 9.0));
t.set_option("color", &json!("nope"));
assert_eq!(t.options()["color"], "#4f80ff");
// The width stays within its range.
t.set_option("width", &json!(100));
assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.1));
t.set_option("width", &json!(0));
assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.0));
}
#[test]
fn create_builds_the_three_tools() {
for id in ["shapeBuilder", "livePaintBucket", "livePaintSelection"] {
+1 -1
View File
@@ -101,7 +101,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("mesh", "Mesh Tool", Some("U"), "tool-mesh")],
&[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")],
&[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend"), t("objectsOnPath", "Objects on Path Tool", None, "tool-objects-on-path")],
&[t("dimension", "Dimension Tool", None, "tool-dimension")],
&[
t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"),
+17
View File
@@ -392,6 +392,16 @@ shown_when = [
hotspot = "The centre."
designer = false
[[cursor]]
id = "Busy"
group = "Navigation"
shown_when = [
"A file is opening off the UI thread.",
"A background save or export is in progress (the status bar shows its name).",
]
hotspot = "The centre."
designer = false
# ── Colour and gradient ───────────────────────────────────────────────────────────────────────────
[[cursor]]
@@ -480,6 +490,13 @@ shown_when = ["The Blend tool over an anchor point (the blend starts there)."]
hotspot = "The crosshair's centre."
look = "Crosshair with a target badge (a ring with a centre dot)."
[[cursor]]
id = "ObjectsOnPath"
group = "Width, blend, slice and shape builder"
shown_when = ["The Objects on Path tool over a path the selected objects can attach to: a click attaches them."]
hotspot = "The crosshair's center."
look = "Crosshair with a badge of small squares along a curve, as on the tool's icon."
[[cursor]]
id = "Slice"
group = "Width, blend, slice and shape builder"
+117 -3
View File
@@ -6,7 +6,9 @@
//! handle end → retract that handle only (#2267); drag a handle → move it independently (Shift: at
//! 45° steps round its anchor); drag a segment → reshape the curve, a selected blend's spine's too,
//! straight or curved (#1111; Shift: its handles at right angles to the line between its anchors,
//! at one length). Blend spine points and handles follow the same conversions (#2544).
//! at one length). Shift held at the press on an anchor moves it instead, handles and all (Shift
//! still held: at 45° steps from where it was). Blend spine points and handles follow the same
//! conversions (#2544).
//! Scissors: click a path to split it there.
use serde_json::json;
@@ -37,6 +39,23 @@ enum State {
start: Point,
began: bool,
},
/// Shift-dragging an anchor (`from`, where it was): it moves, handles and all.
Move {
id: NodeId,
si: usize,
ai: usize,
from: Point,
start: Point,
began: bool,
},
/// Shift-dragging a selected blend's spine point: it moves, handles and all.
SpineMove {
id: NodeId,
ai: usize,
from: Point,
start: Point,
began: bool,
},
/// Dragging a selected blend's spine point into a symmetric smooth point.
SpineConvert {
id: NodeId,
@@ -124,6 +143,7 @@ impl AnchorTool {
};
let rank = |state| match state {
State::Idle => None,
State::Move { id, .. } | State::SpineMove { id, .. } => Some((cx.selection.contains(id), cx.doc.index_path(id))),
State::Convert { id, .. }
| State::Handle { id, .. }
| State::SpineConvert { id, .. }
@@ -170,6 +190,35 @@ impl AnchorTool {
out
}
/// The press on an anchor (or a selected blend's spine point) with Shift held moves it
/// instead of converting it. Any other press is kept.
fn moving(cx: &ToolContext, st: State) -> State {
match st {
State::Convert { id, si, ai, start, .. } => {
match cx.doc.node(id).and_then(|n| n.path_data()).and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai).map(|a| a.p)) {
Some(from) => State::Move { id, si, ai, from, start, began: false },
None => st,
}
}
State::SpineConvert { id, ai, start, .. } => {
match crate::direct::spines(cx)
.into_iter()
.find(|(blend, _)| *blend == id)
.and_then(|(_, path)| path.subpaths.first()?.anchors.get(ai).map(|a| a.p))
{
Some(from) => State::SpineMove { id, ai, from, start, began: false },
None => st,
}
}
_ => st,
}
}
/// How far a Shift-dragged anchor has gone: Shift still held keeps it at 45° steps.
fn move_delta(cx: &ToolContext, start: Point, p: Point, shift: bool) -> vectorcraft_geom::Vec2 {
if shift { constrain_angle_from(p - start, 45.0, cx.constrain_angle) } else { p - start }
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_segment(cx, p, cx.pick_tol()).map(insert_anchor).into_iter().collect()
}
@@ -216,6 +265,9 @@ impl Tool for AnchorTool {
}
("anchorPoint", PointerKind::Down) => {
self.state = Self::press_target(cx, p);
if ev.mods.shift {
self.state = Self::moving(cx, self.state);
}
if matches!(self.state, State::Handle { .. }) {
self.snap = HandleSnap::default();
}
@@ -226,6 +278,8 @@ impl Tool for AnchorTool {
let (began, far) = match self.state {
State::Idle => return out,
State::Convert { began, start, .. }
| State::Move { began, start, .. }
| State::SpineMove { began, start, .. }
| State::Handle { began, start, .. }
| State::SpineConvert { began, start, .. }
| State::SpineHandle { began, start, .. }
@@ -236,10 +290,16 @@ impl Tool for AnchorTool {
return out;
}
out.push(Action::Begin(
if matches!(self.state, State::Convert { .. } | State::SpineConvert { .. }) { "Convert Anchor Point" } else { "Reshape" }
.into(),
match self.state {
State::Convert { .. } | State::SpineConvert { .. } => "Convert Anchor Point",
State::Move { .. } | State::SpineMove { .. } => "Move",
_ => "Reshape",
}
.into(),
));
if let State::Convert { began, .. }
| State::Move { began, .. }
| State::SpineMove { began, .. }
| State::Handle { began, .. }
| State::SpineConvert { began, .. }
| State::SpineHandle { began, .. }
@@ -254,6 +314,14 @@ impl Tool for AnchorTool {
"path.convertAnchor".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
),
State::Move { id, si, ai, from, start, .. } => {
let q = from + Self::move_delta(cx, start, p, ev.mods.shift);
Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "x": q.x, "y": q.y}))
}
State::SpineMove { id, ai, from, start, .. } => {
let q = from + Self::move_delta(cx, start, p, ev.mods.shift);
Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": ai, "x": q.x, "y": q.y}))
}
State::SpineConvert { id, ai, .. } => Action::Preview(
"object.blend.spine.convertAnchor".into(),
json!({"id": id.0, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
@@ -304,6 +372,8 @@ impl Tool for AnchorTool {
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. }
| State::Move { began: true, .. }
| State::SpineMove { began: true, .. }
| State::Handle { began: true, .. }
| State::SpineConvert { began: true, .. }
| State::SpineHandle { began: true, .. }
@@ -367,6 +437,8 @@ impl Tool for AnchorTool {
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. }
| State::Move { began: true, .. }
| State::SpineMove { began: true, .. }
| State::Handle { began: true, .. }
| State::SpineConvert { began: true, .. }
| State::SpineHandle { began: true, .. }
@@ -396,6 +468,9 @@ impl Tool for AnchorTool {
}
_ => match self.state {
State::Reshape { .. } => Cursor::ReshapeSegment,
// A Shift-drag moving an anchor shows the filled arrow, as Direct Selection's does.
State::Move { began: true, .. } | State::SpineMove { began: true, .. } => Cursor::Arrow,
State::Move { .. } | State::SpineMove { .. } => Cursor::AnchorPoint,
State::Idle => press_cursor(cx, p),
State::Convert { .. } | State::Handle { .. } | State::SpineConvert { .. } | State::SpineHandle { .. } => Cursor::AnchorPoint,
},
@@ -856,4 +931,43 @@ mod tests {
assert!(!a.has_out());
assert_eq!(a.h_in, Point::new(160.0, 280.0));
}
/// Shift held at the press on an anchor moves it, handles and all, instead of pulling out
/// handles; Shift still held keeps the move at 45° steps, released frees it. A Shift-click
/// that doesn't move leaves the anchor as it was.
#[test]
fn anchor_point_shift_drag_moves_the_anchor() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 320.0));
let path = vectorcraft_geom::PathData::single(sp);
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Mods::default() };
let moved = |x: f64, y: f64| Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "x": x, "y": y}));
let mut t = AnchorTool::new("anchorPoint");
// Pressed a pixel off the anchor: it keeps that offset to the pointer.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 201.0, 301.0).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 231.0, 341.0));
assert_eq!(a, vec![Action::Begin("Move".into()), moved(230.0, 340.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 241.0, 306.0).with_mods(shift));
assert!(
matches!(&a[..], [Action::Preview(c, v)] if c == "path.moveAnchor" && v["y"].as_f64().is_some_and(|y| (y - 300.0).abs() < 1e-9)),
"{a:?}"
);
assert_eq!(t.cursor(&cx, Point::new(241.0, 306.0), shift), Cursor::Arrow);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 241.0, 306.0)), vec![Action::Commit]);
// Without Shift the same press still pulls out handles.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.convertAnchor"), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 340.0));
// A Shift-click that doesn't move does nothing (no conversion to a corner).
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0).with_mods(shift)).is_empty());
}
}
+1 -1
View File
@@ -1334,7 +1334,7 @@ pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
}
let tol = if cx.smart_guides { cx.snap_tol() } else { cx.tol(cx.snap_tolerance) };
let mut roots = cx.selection.objects.clone();
roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf));
roots.extend(hit_test(cx.doc, p, HitOptions { tol, skip_guides: false, ..cx.hit_options() }).map(|h| h.leaf));
let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
Targets::points_near(cx.doc, &roots, near).styled(cx).snap_point(p, tol)
}
+9
View File
@@ -23,6 +23,7 @@ pub mod intertwine;
pub mod meshblend;
pub mod meshedit;
pub mod mirror;
pub mod objects_on_path;
pub mod params;
pub mod pathtype;
pub mod pen;
@@ -402,6 +403,7 @@ impl ToolContext<'_> {
path_only: self.path_only,
type_path_only: self.type_path_only,
scope: self.isolation,
skip_guides: !self.guides,
}
}
}
@@ -494,6 +496,8 @@ pub enum Cursor {
ZoomOut,
Eyedropper,
NotAllowed,
/// A long operation (opening a file, a background save or export): the OS wait cursor.
Busy,
/// Over the gradient annotator's bar: a click adds a stop.
AddStop,
/// A gradient stop dragged off the bar: releasing deletes it.
@@ -514,6 +518,9 @@ pub enum Cursor {
BlendObject,
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
BlendAnchor,
/// The Objects on Path tool over a path the selected objects can attach to (a click attaches
/// them).
ObjectsOnPath,
/// Over a bracket of selected type on a path (a drag moves it): the arrow with a bracket.
PathBracket,
/// Over the type widget of selected type (a double-click converts point type to area type and
@@ -689,6 +696,7 @@ impl Cursor {
| Cursor::Shaper
| Cursor::LivePaintBucket
| Cursor::Measure
| Cursor::ObjectsOnPath
| Cursor::PrintTiling
| Cursor::SymbolSprayer
| Cursor::SymbolShifter
@@ -807,6 +815,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
.or_else(|| cropimage::create(other))
.or_else(|| cut::create(other))
.or_else(|| intertwine::create(other))
.or_else(|| objects_on_path::create(other))
.or_else(|| touchtype::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
+159
View File
@@ -0,0 +1,159 @@
//! The Objects on Path tool (#2545): a click on a path attaches the selected objects to it
//! (`object.objectsOnPath.attach`). A path they can attach to is highlighted under the pointer,
//! and the pointer shows [`Cursor::ObjectsOnPath`] over it.
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_doc::objects_on_path::{attachable_path, is_objects_on_path};
use vectorcraft_geom::{BezPath, Point};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The highlight of the path under the pointer.
const HIGHLIGHT: [u8; 3] = [0x4f, 0x9d, 0xff];
/// The path a click at `p` attaches the selected objects to: the path object hit there, when it can
/// take objects, is in no Objects on Path group, and something else is selected.
pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> {
let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?;
let n = cx.doc.node(h.leaf)?;
let path = attachable_path(n)?;
if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) {
return None;
}
cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath()))
}
#[derive(Default)]
pub struct ObjectsOnPathTool {
/// The path under the pointer the selection can attach to, and its outline.
hover: Option<(NodeId, BezPath)>,
/// Where the press was, while the button is down.
press: Option<Point>,
}
impl Tool for ObjectsOnPathTool {
fn id(&self) -> &'static str {
"objectsOnPath"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = target(cx, p);
vec![]
}
PointerKind::Down => {
self.press = Some(p);
vec![]
}
PointerKind::Up => {
// A press that moved away is not a click.
let Some(start) = self.press.take().filter(|q| q.distance(p) < cx.tol(3.0)) else { return vec![] };
let Some((path, _)) = target(cx, start) else { return vec![] };
self.hover = None;
vec![Action::Exec("object.objectsOnPath.attach".into(), json!({ "path": path.0 }))]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
self.hover.iter().map(|(_, path)| Overlay::Path { path: path.clone(), color: HIGHLIGHT, width: 2.0, dashed: false }).collect()
}
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
if target(cx, p).is_some() { Cursor::ObjectsOnPath } else { Cursor::Arrow }
}
/// Escape while the button is down cancels the click: the release attaches nothing.
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape {
self.press = None;
self.hover = None;
}
vec![]
}
fn busy(&self) -> bool {
self.press.is_some()
}
}
pub fn create(id: &str) -> Option<Box<dyn crate::Tool>> {
(id == "objectsOnPath").then(|| Box::new(ObjectsOnPathTool::default()) as Box<dyn crate::Tool>)
}
#[cfg(test)]
mod tests {
use vectorcraft_doc::{Appearance, Document, Node, NodeKind, Selection};
use vectorcraft_geom::{Rect, shapes};
use super::*;
use crate::testutil::{cx, paint};
fn doc() -> (Document, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let line = d.alloc_id();
let square = d.alloc_id();
d.insert(Some(l), 0, Node::path(line, shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), Appearance::default_art())).unwrap();
d.insert(Some(l), 1, Node::path(square, shapes::rectangle(Rect::new(50.0, 50.0, 70.0, 70.0)), Appearance::default_art())).unwrap();
(d, line, square)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn a_click_on_a_path_attaches_the_selection_to_it() {
let (d, line, square) = doc();
let mut sel = Selection::default();
sel.set([square]);
let paint = paint();
let cx = cx(&d, &sel, &paint);
let mut t = ObjectsOnPathTool::default();
let on = Point::new(200.0, 300.0);
assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::ObjectsOnPath);
assert_eq!(t.cursor(&cx, Point::new(200.0, 500.0), Mods::default()), Cursor::Arrow, "away from paths");
t.pointer(&cx, &ev(PointerKind::Move, on.x, on.y));
assert_eq!(t.overlays(&cx).len(), 1, "the path is highlighted");
t.pointer(&cx, &ev(PointerKind::Down, on.x, on.y));
let acts = t.pointer(&cx, &ev(PointerKind::Up, on.x, on.y));
assert!(matches!(acts.as_slice(), [Action::Exec(id, p)] if id == "object.objectsOnPath.attach" && p["path"] == line.0), "{acts:?}");
}
#[test]
fn escape_during_the_press_cancels_the_click() {
let (d, _, square) = doc();
let mut sel = Selection::default();
sel.set([square]);
let paint = paint();
let cx = cx(&d, &sel, &paint);
let mut t = ObjectsOnPathTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 200.0, 300.0));
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
assert!(t.busy());
t.key(&cx, ToolKey::Escape, Mods::default());
assert!(!t.busy() && t.overlays(&cx).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty(), "the release attaches nothing");
}
#[test]
fn nothing_attaches_without_other_objects_selected() {
let (d, line, _) = doc();
let mut sel = Selection::default();
sel.set([line]);
let paint = paint();
let cx = cx(&d, &sel, &paint);
let mut t = ObjectsOnPathTool::default();
assert_eq!(t.cursor(&cx, Point::new(200.0, 300.0), Mods::default()), Cursor::Arrow);
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty());
assert!(matches!(d.node(line).map(|n| &n.kind), Some(NodeKind::Path { .. })));
}
}
+4 -2
View File
@@ -663,8 +663,10 @@ mod tests {
2.0,
Point::new(inch(0.01), inch(0.03)),
Point::new(inch(3.07), inch(3.27)),
Point::new(inch(3.06), inch(3.28)),
inch(0.05),
// An inch ruler's ticks are binary fractions, so at zoom 2 they are 1/16 in
// apart, not the 0.05 in a decimal ladder gave (#2827).
Point::new(inch(3.0725), inch(3.28)),
inch(0.0625),
),
]
}
+32
View File
@@ -1679,6 +1679,38 @@ mod tests {
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
}
/// #3036: with Lock Guides on, a guide object neither takes the press nor stops a marquee
/// from starting over it; with guides unlocked, the press still picks it.
#[test]
fn marquee_passes_through_locked_guide_objects() {
let (mut d, id) = doc_with_rect();
let l = d.layers[0].id;
let g = d.alloc_id();
let pts = [Point::new(20.0, 20.0), Point::new(400.0, 20.0), Point::new(400.0, 400.0), Point::new(20.0, 400.0)];
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true));
let mut guide = vectorcraft_doc::Node::path(g, path, vectorcraft_doc::Appearance::default_art());
let vectorcraft_doc::NodeKind::Path { guide: flag, .. } = &mut guide.kind else { panic!("a path") };
*flag = true;
d.insert(Some(l), 1, guide).unwrap();
let s = Selection::default();
let p = paint();
let locked = ToolContext { guides: false, ..cx(&d, &s, &p) };
for (x, y) in [(20.0, 60.0), (50.0, 50.0)] {
let mut t = SelectionTool::default();
assert!(t.pointer(&locked, &ev(PointerKind::Down, x, y)).is_empty(), "press at ({x}, {y})");
t.pointer(&locked, &ev(PointerKind::Drag, 250.0, 250.0));
assert!(matches!(t.state, State::Marquee { .. }), "press at ({x}, {y})");
// The rectangle is rubber-band selected (the engine leaves locked guides out).
let a = t.pointer(&locked, &ev(PointerKind::Up, 250.0, 250.0));
let Some(Action::Exec(c, v)) = a.first() else { panic!("press at ({x}, {y}): {a:?}") };
assert_eq!(c, "select.set", "press at ({x}, {y})");
assert!(v["ids"].as_array().unwrap().contains(&json!(id.0)), "press at ({x}, {y}): {a:?}");
}
let unlocked = ToolContext { guides: true, ..cx(&d, &s, &p) };
let mut t = SelectionTool::default();
assert_eq!(t.pointer(&unlocked, &ev(PointerKind::Down, 20.0, 60.0)), vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]);
}
#[test]
fn handle_drag_scales() {
let (d, id) = doc_with_rect();
+2
View File
@@ -34,6 +34,8 @@ const ROWS: &[Row] = &[
(&["smooth"], None, &["fidelity"]),
(&["blobBrush", "eraser"], None, &["size"]),
(&["blobBrush"], None, &["keepSelected", "mergeOnlySelection"]),
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
(&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]),
(&["polygon"], None, &["sides"]),
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
+2 -2
View File
@@ -633,7 +633,7 @@ impl Tool for TypeTool {
edit::prev_word(&text, self.caret)
} else {
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, false)
} else {
edit::prev_cluster(&text, self.caret)
}
@@ -653,7 +653,7 @@ impl Tool for TypeTool {
edit::next_word(&text, self.caret)
} else {
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, true)
} else {
edit::next_cluster(&text, self.caret)
}
+169
View File
@@ -44,6 +44,175 @@ fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) {
}
}
#[test]
fn type_arrows_keep_devanagari_clusters_with_an_rtl_suffix() {
// Use the text tests' installed-family order, requiring coverage of the unchanged Hindi input.
// The Hebrew suffix selects the Type tool's existing visual-arrow route.
let hindi = "हिन्दी";
let second = "हि".len();
assert_eq!((second, hindi.len()), (6, 18));
let db = FontDb::global();
let face = ["Kohinoor Devanagari", "ITF Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Nirmala UI", "Mangal"]
.into_iter()
.filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name))
.find(|face| hindi.chars().all(|ch| face.covers(ch)));
let Some(face) = face else {
eprintln!("SKIPPED: type_arrows_keep_devanagari_clusters_with_an_rtl_suffix; no installed candidate font covers the Hindi input");
return;
};
assert!(hindi.chars().all(|ch| face.covers(ch)), "the selected face covers the unchanged Hindi prefix");
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..Default::default() };
// The plain-LTR control runs first. Each case uses a fresh document and tool.
for (case, text, visual_route) in [("plain_ltr", "हिन्दी", false), ("mixed_rtl", "हिन्दी שלום", true)] {
let (mut d, id, mut tool) = editing(text);
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text fixture") };
for run in &mut t.runs {
run.style = style.clone();
}
assert_eq!(t.plain_text(), text);
assert_eq!(&text[..hindi.len()], hindi);
let lay = vectorcraft_text::layout(db, t);
assert_eq!(lay.lines.len(), 1, "{case}: one ordinary horizontal line");
assert!(!lay.vertical, "{case}: horizontal Type");
let mut hindi_spans: Vec<_> = lay.glyphs.iter().filter(|g| g.byte < hindi.len()).map(|g| (g.byte, g.len)).collect();
hindi_spans.sort_unstable();
hindi_spans.dedup();
// Raw shaping spans are diagnostic; editing boundaries do not depend on their count.
assert!(!hindi_spans.is_empty(), "{case}: Hindi glyph source spans are present");
for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) {
let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face");
assert!(used.file_data() == face.file_data(), "{case}: Hindi uses the selected face bytes");
assert_eq!(used.face_index(), face.face_index(), "{case}: Hindi uses the selected face index");
assert_ne!(glyph.gid, 0, "{case}: Hindi glyph is present");
assert!(!glyph.rtl, "{case}: Hindi prefix remains LTR");
}
assert_eq!(lay.glyphs.iter().any(|g| g.rtl), visual_route, "{case}: actual Type arrow-route predicate");
if visual_route {
assert!(lay.glyphs.iter().any(|g| g.rtl && g.byte > hindi.len()), "{case}: the ordinary suffix supplies RTL glyphs");
}
assert_eq!(edit::next_cluster(text, second), hindi.len(), "{case}: logical cluster end");
assert_eq!(edit::prev_cluster(text, hindi.len()), second, "{case}: logical cluster start");
tool.caret = second;
tool.anchor = second;
assert_eq!(tool.editing, Some(id));
assert_eq!(tool.sel(), (second, second), "{case}: collapsed caret before Right");
assert!(tool.typing.is_none(), "{case}: no pending typing session");
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!((tool.caret, tool.anchor), (hindi.len(), hindi.len()), "{case}: Right must move 6 -> 18 across the Hindi cluster");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!((tool.caret, tool.anchor), (second, second), "{case}: Left must move 18 -> 6 across the Hindi cluster");
}
}
#[test]
fn type_arrows_keep_devanagari_clusters_across_wrapped_lines() {
use vectorcraft_doc::{Composer, Justify};
use vectorcraft_geom::{Affine, PathData};
// Use the installed-family order and unchanged Hindi input from the single-line test.
// Shaping and wrapping capabilities vary by face; an eligibility skip is not arrow coverage.
let hindi = "हिन्दी";
let text = "हिन्दी שלום";
let second = "हि".len();
let split = "हिन्".len();
assert_eq!((second, split, hindi.len()), (6, 12, 18));
let db = FontDb::global();
let face = ["Kohinoor Devanagari", "ITF Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Nirmala UI", "Mangal"]
.into_iter()
.filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name))
.find(|face| hindi.chars().all(|ch| face.covers(ch)));
let Some(face) = face else {
eprintln!("SKIPPED: type_arrows_keep_devanagari_clusters_across_wrapped_lines; no installed candidate font covers the Hindi input");
return;
};
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..Default::default() };
let (mut d, id, mut tool) = editing(text);
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text fixture") };
for run in &mut t.runs {
run.style = style.clone();
}
t.para.justify = Justify::Left;
t.para.composer = Composer::SingleLine;
t.para.hyphenate = false;
assert_eq!(t.plain_text(), text);
assert_eq!(&text[..hindi.len()], hindi);
// Measure once on the selected face. No width sweep or invented line endpoints.
let natural = vectorcraft_text::layout(db, t);
assert_eq!(natural.lines.len(), 1, "setup: natural point text is a single line");
assert!(!natural.vertical, "setup: horizontal text");
let hindi_glyphs: Vec<_> = natural.glyphs.iter().filter(|g| g.byte < hindi.len()).collect();
assert!(!hindi_glyphs.is_empty(), "setup: actual Hindi glyphs exist");
for glyph in &hindi_glyphs {
let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face");
assert!(used.file_data() == face.file_data(), "setup: Hindi uses the selected face bytes");
assert_eq!(used.face_index(), face.face_index(), "setup: Hindi uses the selected face index");
assert_ne!(glyph.gid, 0, "setup: Hindi glyph is present");
assert!(!glyph.rtl, "setup: Hindi prefix is LTR");
assert!(glyph.len > 0 && glyph.byte + glyph.len <= hindi.len(), "setup: Hindi glyph source range");
assert!(
text.is_char_boundary(glyph.byte) && text.is_char_boundary(glyph.byte + glyph.len),
"setup: glyph source ranges are UTF-8 boundaries"
);
}
let prefix_width: f64 = hindi_glyphs.iter().filter(|g| g.byte < split).map(|g| g.advance).sum();
let split_width: f64 = hindi_glyphs.iter().filter(|g| g.byte == split).map(|g| g.advance).sum();
assert!(prefix_width.is_finite() && prefix_width > 0.0, "setup: prefix has positive finite advance");
assert!(split_width.is_finite(), "setup: the internal raw-group advance is finite");
if split_width <= 0.0 {
eprintln!(
"SKIPPED: type_arrows_keep_devanagari_clusters_across_wrapped_lines; layout capability: the selected face has no positive-advance raw group at internal byte {split}"
);
return;
}
let width = prefix_width + split_width * 0.5;
assert!(width.is_finite() && width > prefix_width && width < prefix_width + split_width, "setup: one measured width separates the raw groups");
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 500.0).to_path(0.1)) };
t.xf = Affine::IDENTITY;
assert_eq!((t.area.rows, t.area.columns, t.area.inset), (1, 1, 0.0));
assert_eq!((t.para.left_indent, t.para.first_line_indent, t.para.right_indent), (0.0, 0.0, 0.0));
let lay = vectorcraft_text::layout(db, t);
assert!(!lay.overflow, "setup: the full RTL suffix fits the tall frame");
assert!(!lay.vertical, "setup: horizontal area text");
assert!(lay.glyphs.iter().any(|g| g.rtl && g.byte > hindi.len()), "setup: actual RTL suffix selects Type's visual-arrow route");
for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) {
let used = db.face_by_id(glyph.font_id).expect("the wrapped Hindi glyph's loaded face");
assert!(used.file_data() == face.file_data(), "setup: wrapped Hindi uses the selected face bytes");
assert_eq!(used.face_index(), face.face_index(), "setup: wrapped Hindi uses the selected face index");
assert_ne!(glyph.gid, 0, "setup: wrapped Hindi glyph is present");
assert!(!glyph.rtl, "setup: wrapped Hindi prefix stays LTR");
}
assert_eq!(edit::next_cluster(text, second), hindi.len(), "setup: logical cluster end is 18");
assert_eq!(edit::prev_cluster(text, hindi.len()), second, "setup: logical cluster start is 6");
assert_eq!(edit::prev_cluster(text, split), second, "setup: raw line endpoint 12 is inside the grapheme");
assert_eq!(edit::next_cluster(text, split), hindi.len(), "setup: raw line endpoint 12 is not its end");
if lay.lines.len() < 2 || (lay.lines[0].start, lay.lines[0].end, lay.lines[1].start) != (0, split, split) {
eprintln!(
"SKIPPED: type_arrows_keep_devanagari_clusters_across_wrapped_lines; layout capability: the measured frame does not expose adjacent wrapped endpoints at internal byte {split}"
);
return;
}
assert!(lay.lines.len() >= 2, "setup: actual wrapping occurred");
assert_eq!(
(lay.lines[0].start, lay.lines[0].end, lay.lines[1].start),
(0, split, split),
"setup: actual adjacent line endpoints reach internal byte 12"
);
assert_eq!(lay.line_of(second), 0, "setup: caret 6 is on the preceding line");
assert!(!lay.lines[0].rtl, "setup: the preceding paragraph line is LTR");
tool.caret = second;
tool.anchor = second;
assert_eq!(tool.editing, Some(id));
assert_eq!(tool.sel(), (second, second), "setup: collapsed caret before Right");
assert!(tool.typing.is_none(), "setup: no pending typing session");
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!((tool.caret, tool.anchor), (hindi.len(), hindi.len()), "wrapped Right must move 6 -> 18 across the Hindi grapheme");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!((tool.caret, tool.anchor), (second, second), "wrapped Left must move 18 -> 6 across the Hindi grapheme");
}
#[test]
fn typing_is_one_coalesced_preview() {
let (mut d, id, mut tool) = editing("Hello");
+406 -16
View File
@@ -8,6 +8,7 @@ use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
use crate::opening;
use crate::state::View;
use crate::theme::{self, Tokens};
use crate::{CacheKey, VectorcraftApp, now_ms, widgets};
@@ -189,6 +190,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
v.zoom = zoom;
}
/// Paint the document canvas, complete or cached artwork, navigation and tool overlays.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
@@ -283,7 +285,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Artwork raster.
let ppp = ui.ctx().pixels_per_point();
let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32);
// Match Renderer::render_as before recording the cache extent or building its view.
let (w, h) = (
((rect.width() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)),
((rect.height() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)),
);
// View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel
// is bigger than a screen pixel (below that the screen render already shows it).
let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
@@ -338,6 +344,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.perf.render_ms = done.ms;
app.canvas.last_ms = done.ms;
app.canvas.moved = done.moved > 0;
app.canvas.from_worker = true;
}
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let (w, h, view) = match pixel {
@@ -388,10 +395,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
// A document switch renders synchronously so another document's frame is never shown.
// The first frame after an interaction, when the frame shown drew moved rasters, also goes
// to the worker.
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
// thread's renderer has none, so a frame fast on the worker would be slow here.
let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc);
let settling = app.canvas.moved && !key.interacting;
let heavy = (app.canvas.last_ms > 8.0 || settling) && app.canvas.texture.is_some() && same_doc;
let staying = key.interacting && app.canvas.from_worker;
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc;
match (&mut app.canvas.worker, heavy) {
(Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
_ => {
@@ -402,12 +412,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
app.canvas.from_worker = false;
}
}
}
if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key)
&& k.doc == key.doc
{
// The frame being shown covers the canvas rect it was rendered for. While a panel is
// dragged that rect is a different size, so reprojecting the frame through the *current*
// rect stretches the art to the new size until the worker's frame arrives, then it snaps
// back. Rebuild the rect it was rendered for from the key's own pixel size instead.
let old_rect = {
let ppp = if k.ppp > 0.0 { k.ppp } else { 1.0 };
egui::Rect::from_center_size(rect.center(), vec2(k.w as f32 / ppp, k.h as f32 / ppp))
};
match k.pixel {
// Document pixels: placed over the document rect they cover, turning with the view.
Some([x0, y0, x1, y1]) => {
@@ -415,17 +434,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
None if (k.rot - v.rotation).abs() < 1e-9 => {
// Reproject the last frame if it was rendered for a different view.
let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot };
let a = xf.to_screen(old.to_doc(rect.min));
let b = xf.to_screen(old.to_doc(rect.max));
let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot };
let a = xf.to_screen(old.to_doc(old_rect.min));
let b = xf.to_screen(old.to_doc(old_rect.max));
painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
// The view turned after the last frame was rendered, and the worker renders the frame at
// the new angle. Until it arrives, the last frame is drawn turned into this view: each
// corner goes where this view puts the document point that the corner showed.
None => {
let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
let q = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c)));
let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
let q =
[old_rect.left_top(), old_rect.right_top(), old_rect.right_bottom(), old_rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c)));
textured_quad(&painter, tex.id(), &q, (1.0, 1.0));
}
}
@@ -518,6 +538,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ime_output(app, ui.ctx(), &xf);
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
// A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial
// at the canvas center, the point the view turns about.
if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::RotateView { .. })) {
rotate_view_dial(&painter, rect.center(), v.rotation, &t);
}
if app.ui.view.rulers && app.ui.screen_mode < 3 {
if let Some(origin) = origin_preview {
@@ -536,6 +561,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
artboard_name_editor(app, ui, &xf);
// Cursor.
if resp.hovered() {
if opening::long_operation_busy(app) {
ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy));
return;
}
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let drag = ui.data(|d| d.get_temp::<Drag>(drag_id()));
@@ -638,6 +667,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::NotAllowed => C::NotAllowed,
Cursor::Busy => C::Progress,
Cursor::AddStop => C::Copy,
Cursor::RemoveStop => C::NotAllowed,
Cursor::Slice => C::Crosshair,
@@ -669,6 +699,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::SymbolStainer
| Cursor::SymbolScreener
| Cursor::SymbolStyler
| Cursor::ObjectsOnPath
| Cursor::Scissors
| Cursor::ScissorsNotAllowed
| Cursor::MeshAdd
@@ -1524,11 +1555,26 @@ fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32, look: LineLook) {
}
/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole
/// numbers, or as many decimals as the step has.
/// numbers, or the fewest decimals that write `step` exactly, so a binary step keeps its value
/// (an inch ruler's 1/16 reads `0.0625`, not the `0.06` that rounding the step's magnitude gave).
/// How far down the left ruler a label of `chars` characters reaches, counting from its top: it
/// is spelled one character per line, [`RULER_CHAR`] apart, and the last one is a line tall.
pub(crate) fn vertical_label_height(chars: usize) -> f32 {
chars.saturating_sub(1) as f32 * RULER_CHAR + RULER_LINE
}
/// A ruler label's line spacing and line height on the left ruler.
const RULER_CHAR: f32 = 8.5;
const RULER_LINE: f32 = 9.5;
pub(crate) fn ruler_label(v: f64, step: f64) -> String {
let decimals = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize };
let exact = |d: u32| (step * 10f64.powi(d as i32)).fract().abs() < 1e-9;
let decimals = (0..=7).find(|d| exact(*d)).unwrap_or(7) as usize;
let s = format!("{v:.decimals$}");
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s }
// Trailing zeros go, as `Unit::number` drops them: a sixteenth-inch ruler labels the inch `1`,
// not `1.0000`, which the vertical ruler would spell down six lines.
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s };
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s.into() }
}
/// The rulers, numbered in `unit` (the General unit).
@@ -1638,7 +1684,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
// (`per` points each).
let per = unit.points();
let step = unit.ruler_step(xf.zoom);
let minor = step / 10.0;
let minor = unit.ruler_minor(step);
let font = egui::FontId::proportional(9.5);
let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01;
// A tick's length: the labelled ones longest, the halves between them shorter.
@@ -1674,14 +1720,24 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
}
});
let clip_left = p.with_clip_rect(left);
// A label spelled down the ruler is tall, and a long one can reach the next label's place:
// "-0.0625" is seven characters, 60 pt of ruler, where a sixteenth-inch step leaves 54 pt at
// zoom 12. The ticks all stay; a label that would run into the one above it is left out.
let mut filled_to = f32::NEG_INFINITY;
ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, y| {
let sy = left.top() + d;
clip_left.line_segment([pos2(left.right() - tick(y), sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if near(y, step) {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
for (k, ch) in ruler_label(y, step).chars().enumerate() {
let text = ruler_label(y, step);
let top = sy + 2.0;
if top < filled_to {
return;
}
filled_to = top + vertical_label_height(text.chars().count());
for (k, ch) in text.chars().enumerate() {
clip_left.text(
pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5),
pos2(left.left() + 4.0, top + k as f32 * RULER_CHAR),
egui::Align2::LEFT_TOP,
ch.to_string(),
font.clone(),
@@ -1869,6 +1925,7 @@ fn hit_with<T>(
path_only: prefs.object_selection_by_path_only,
type_path_only: prefs.type_selection_by_path_only,
scope: app.session.active().and_then(|st| st.isolation),
..Default::default()
};
test(&app.session.active()?.doc, p, opt)
}
@@ -1970,8 +2027,8 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
// Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on.
if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting {
// Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on and Snap to Grid and Pixel Preview off.
if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting || app.ui.view.snap_to_grid || app.ui.view.pixel_preview {
return;
}
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
@@ -2293,6 +2350,26 @@ fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
}
/// The radius of the Rotate View dial, in screen points.
const DIAL_RADIUS: f32 = 40.0;
/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a
/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the
/// angle under the center, rounded to whole degrees as in the status bar.
fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) {
// `dir(deg)` is the screen direction `deg` degrees clockwise from straight up.
let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos());
p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border));
for i in 0..24u8 {
let len = if i % 6 == 0 { 8.0 } else { 4.0 };
let d = dir(f32::from(i) * 15.0);
p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim));
}
p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent));
p.circle_filled(c, 2.5, t.accent);
p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text);
}
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
@@ -4694,6 +4771,107 @@ mod tests {
assert_eq!(app.canvas.renderer.stats.previewed, 0);
}
/// Dragging a small square over an A4 page filled by a rectangle blurred by 500 pt, at 200%:
/// the blur reaches far past the raster budget, so the still frame keeps the backdrop's raster
/// cropped to the view, and each frame of a Selection tool drag draws it from there instead of
/// previewing it at the drag's lower resolution: on this thread, and on the worker, where the
/// frames of a heavy document render.
#[test]
fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() {
for on_worker in [false, true] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap();
app.canvas.worker_started = true; // no worker unless `on_worker`
let back = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [back], "color": "#1a4ce6"})).unwrap();
app.run("effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 500}, "ids": [back]})).unwrap();
let square = app.run("shape.rectangle", json!({"x": 260, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [square]})).unwrap();
app.select_tool("selection");
let ctx = egui::Context::default();
// One headless frame with `events`, then, on the worker, frames until the frame it renders
// for them is shown.
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let frame = |app: &mut VectorcraftApp, events| {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
frame(app, events);
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
while app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
frame(app, vec![]);
}
};
let moved = |app: &VectorcraftApp| if on_worker { app.canvas.moved } else { app.canvas.renderer.stats.moved > 0 };
run(&mut app, vec![]);
if let Some(vm) = app.view_mut() {
(vm.zoom, vm.center) = (2.0, Point::new(290.0, 410.0));
}
if on_worker {
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
app.canvas.last_ms = 50.0;
}
run(&mut app, vec![]);
assert_eq!(app.canvas.from_worker, on_worker);
assert!(!moved(&app), "a still frame, on the worker: {on_worker}");
let at = app.canvas_rect.unwrap().center();
let button =
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
run(&mut app, vec![egui::Event::PointerMoved(at)]);
run(&mut app, vec![button(at, true)]);
for step in [1.0, 2.0, 3.0] {
run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]);
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}, on the worker: {on_worker}");
assert_eq!(app.canvas.from_worker, on_worker, "step {step}");
assert!(
!moved(&app),
"the blur drawn from the still frame's raster, step {step}, on the worker: {on_worker} ({} ms)",
app.perf.render_ms
);
}
run(&mut app, vec![button(at + vec2(39.0, 21.0), false)]);
let info = app.run("document.inspect", json!({})).unwrap();
let children = info["layers"][0]["children"].as_array().unwrap().clone();
let x0 = |id: &serde_json::Value| children.iter().find(|c| c["id"] == *id).unwrap()["bounds"]["x"].as_f64().unwrap();
assert!(x0(&square) > 270.0 && x0(&back) == 0.0, "the square moved, the backdrop did not");
app.canvas.worker = None;
}
}
/// An interaction's frames stay on the worker once it drew the frame shown, however fast that
/// frame was: the worker's renderer keeps the effect rasters that made it fast, and this
/// thread's renderer, without them, would draw the next frame slowly. Without a frame from the
/// worker, a fast frame renders on this thread as before.
#[test]
fn an_interaction_stays_on_the_worker_that_drew_the_frame_shown() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
app.canvas.worker_started = true; // the first frame renders on this thread
let ctx = egui::Context::default();
shapes(&mut app, &ctx);
for from_worker in [true, false] {
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
(app.canvas.from_worker, app.canvas.last_ms) = (from_worker, 1.0);
app.session.begin_interaction("Preview").unwrap();
shapes(&mut app, &ctx);
let pending = app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.interacting);
assert_eq!(pending, from_worker, "sent to the worker: {from_worker}");
if !from_worker {
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "rendered on this thread");
}
app.session.cancel_interaction().unwrap();
app.canvas.worker = None;
shapes(&mut app, &ctx);
}
}
/// The frame after an interaction renders without `interacting` also when the interaction
/// leaves the document as it was (#1257): here a drag back to where it started.
#[test]
@@ -4722,6 +4900,134 @@ mod tests {
assert!(!app.canvas.moved);
}
/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size.
/// Keep the worker controlled so huge virtual viewports never allocate huge test images.
#[test]
fn canvas_render_jobs_use_renderer_limited_dimensions() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.canvas.worker_started = true;
app.ui.view.rulers = false;
let frame = |app: &mut VectorcraftApp, size, ppp| {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() };
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
let mut output = ctx.run_ui(input, |ui| show(app, ui));
output.textures_delta.clear();
};
frame(&mut app, vec2(400.0, 300.0), 1.0);
let (worker, jobs, _completed) = crate::render_worker::Worker::test_channel();
app.canvas.worker = Some(worker);
app.canvas.last_ms = 50.0;
for (size, ppp, wide) in [(vec2(70000.25, 32.0), 1.0, true), (vec2(32.0, 70000.25), 1.0, false), (vec2(40000.25, 32.0), 2.0, true)] {
frame(&mut app, size, ppp);
let job = jobs.try_recv().expect("oversized viewport queued on controlled worker");
assert_eq!((job.key.w, job.key.h), (job.w, job.h));
assert_eq!(if wide { job.w } else { job.h }, u32::from(u16::MAX));
assert!((1..=u32::from(u16::MAX)).contains(&job.w));
assert!((1..=u32::from(u16::MAX)).contains(&job.h));
let center = job.view * Point::new(job.key.cx, job.key.cy);
assert!((center.x - f64::from(job.w) / 2.0).abs() < 0.001);
assert!((center.y - f64::from(job.h) / 2.0).abs() < 0.001);
}
}
/// A cached texture keeps the renderer's document mapping when the viewport changes. The
/// controlled worker cannot finish early, even if egui repeats a layout pass.
#[test]
fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() {
for (old_rot, new_rot, old_ppp, new_ppp, pixel, resize_only) in [
// Isolate the original panel/window resize: zoom, pan, rotation and DPI stay fixed.
(0.0, 0.0, 1.0, 1.0, false, true),
(30.0, 30.0, 1.25, 1.25, false, true),
(0.0, 0.0, 1.0, 1.0, false, false),
(30.0, 30.0, 1.25, 1.25, false, false),
(30.0, 75.0, 1.25, 1.25, false, false),
(30.0, 75.0, 1.25, 2.0, false, false),
(30.0, 30.0, 2.0, 1.25, false, false),
(30.0, 75.0, 1.25, 2.0, true, false),
] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.canvas.worker_started = true;
app.ui.view.pixel_preview = pixel;
let frame = |app: &mut VectorcraftApp, size: egui::Vec2, ppp: f32| {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(31.25, 19.5), size)), ..Default::default() };
// Monitor DPI is viewport input, not egui's UI zoom (which rescales the last
// screen rect and would override the simultaneous resize under test).
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
let mut out = ctx.run_ui(input, |ui| show(app, ui));
assert!((out.pixels_per_point - ppp).abs() < 0.001);
out.textures_delta.clear();
let tex = app.canvas.texture.as_ref().expect("rendered canvas texture").id();
out.shapes
.into_iter()
.find_map(|s| match s.shape {
Shape::Mesh(m) if m.texture_id == tex => Some(m),
_ => None,
})
.expect("the canvas texture is drawn")
};
frame(&mut app, vec2(800.25, 600.35), old_ppp);
{
let v = app.view_mut().unwrap();
v.rotation = old_rot;
v.zoom = 2.5;
v.center = Point::new(210.0, 160.0);
}
frame(&mut app, vec2(800.25, 600.35), old_ppp);
let old = app.canvas.key.clone().unwrap();
let (worker, jobs, completed) = crate::render_worker::Worker::test_channel();
app.canvas.worker = Some(worker);
app.canvas.last_ms = 50.0;
{
let v = app.view_mut().unwrap();
v.rotation = new_rot;
if !resize_only {
v.zoom = 3.25;
v.center = Point::new(225.0, 170.0);
}
}
let size = vec2(602.5, 720.1);
let mesh = frame(&mut app, size, new_ppp);
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the cached frame is still displayed");
let job = jobs.try_recv().expect("the replacement frame was submitted");
assert_eq!(job.key.ppp, new_ppp);
assert_eq!(job.key.rot, new_rot);
if resize_only {
assert_eq!((job.key.zoom, job.key.cx, job.key.cy, job.key.rot, job.key.ppp), (old.zoom, old.cx, old.cy, old.rot, old.ppp));
}
assert_ne!((job.key.w, job.key.h), (old.w, old.h));
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let render_view = Affine::translate((old.w as f64 / 2.0, old.h as f64 / 2.0))
* Affine::rotate(old.rot.to_radians())
* Affine::scale(old.zoom * old.ppp as f64)
* Affine::translate((-old.cx, -old.cy));
assert_eq!(mesh.vertices.len(), 4);
for vertex in &mesh.vertices {
assert!([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)].contains(&vertex.uv));
let doc = if let Some([x0, y0, x1, y1]) = old.pixel {
Point::new(x0 as f64 + vertex.uv.x as f64 * (x1 - x0) as f64, y0 as f64 + vertex.uv.y as f64 * (y1 - y0) as f64)
} else {
render_view.inverse() * Point::new(vertex.uv.x as f64 * old.w as f64, vertex.uv.y as f64 * old.h as f64)
};
assert!(
(vertex.pos - xf.to_screen(doc)).length() < 0.002,
"{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}"
);
}
// Deliver the actual replacement and verify that it replaces the cached key.
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
let expected = job.key.clone();
completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap();
frame(&mut app, size, new_ppp);
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
}
}
/// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on
/// a heavy document), the canvas draws the last frame turned until that frame arrives: each
/// corner goes where the new view puts the document point that the corner showed. The new frame
@@ -5243,6 +5549,37 @@ mod tests {
assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them");
}
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer does not
/// show with View › Snap to Grid or View › Pixel Preview on, even with Smart Guides and the option on.
#[test]
fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
let outlines = |app: &mut VectorcraftApp| {
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
};
assert!(app.ui.view.smart_guides && app.session.prefs.object_highlighting);
assert!(outlines(&mut app) > 0, "highlighted with Snap to Grid and Pixel Preview off");
app.run("view.snapToGrid", json!({})).unwrap();
assert!(app.ui.view.snap_to_grid);
assert_eq!(outlines(&mut app), 0, "Snap to Grid on");
app.run("view.snapToGrid", json!({})).unwrap();
assert!(outlines(&mut app) > 0, "Snap to Grid off again");
app.run("view.pixelPreview", json!({})).unwrap();
assert!(app.ui.view.pixel_preview);
assert_eq!(outlines(&mut app), 0, "Pixel Preview on");
app.run("view.pixelPreview", json!({})).unwrap();
assert!(outlines(&mut app) > 0, "Pixel Preview off again");
}
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer
/// shows with Smart Guides on and the option on, and not otherwise.
#[test]
@@ -6120,6 +6457,59 @@ mod tests {
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
}
#[test]
fn a_rotate_view_drag_shows_a_dial_with_the_angle() {
use egui::{Event, Modifiers};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.select_tool("rotateView");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let c = app.canvas_rect.unwrap().center();
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
// `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's
// direction in whole degrees clockwise from straight up, and the angle text.
let dial = |app: &mut VectorcraftApp| {
let s = shapes(app, &ctx);
let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None });
let needle = s.iter().find_map(|s| match s {
Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => {
Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32)
}
_ => None,
});
let text =
s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None });
(circle, needle, text)
};
assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag");
// The Rotate View tool: a drag from 0° to 30° about the canvas center.
let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0));
frame(&mut app, &ctx, vec![Event::PointerMoved(right)]);
frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]);
assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string())));
frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]);
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view());
// A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°.
app.select_tool("rectangle");
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]);
frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]);
frame(&mut app, &ctx, vec![Event::PointerMoved(below)]);
assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string())));
assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view());
// Presentation Mode turned on during the drag hides the dial.
let mode = app.ui.screen_mode;
app.ui.screen_mode = 3;
assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode");
app.ui.screen_mode = mode;
frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]);
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]);
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
}
#[test]
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
use egui::{Event, Modifiers};
+473 -41
View File
@@ -1,6 +1,7 @@
//! Window chrome: application bar (menus), Control bar, document tabs, status bar.
use std::borrow::Cow;
use std::sync::Arc;
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
use serde_json::json;
@@ -645,6 +646,7 @@ fn artboard_list(app: &VectorcraftApp, ui: &mut Ui, cur: usize) -> Option<usize>
.inner
}
/// Draw the status message, bounded readouts and artboard navigation controls.
pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("status_bar")
@@ -720,50 +722,148 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
})
.unwrap_or("");
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
egui::RichText::new(format!(
"{} {:.1} ms · {} {:.1} ms · {:.0} fps",
crate::i18n::tr_ctx(crate::i18n::current(), "diagnostics", "render"),
app.perf.render_ms,
crate::i18n::tr_ctx(crate::i18n::current(), "diagnostics", "ui"),
app.perf.frame_ms,
app.perf.fps
))
.size(10.5)
.color(t.text_disabled),
);
if let Some(p) = app.hover_doc {
let u = app.session.general_unit();
ui.label(
egui::RichText::new(format!("X: {} Y: {}", u.readout(p.x), u.readout(p.y))).font(theme::mono(10.5)).color(t.text_dim),
);
}
// Background saves, exports and opens in progress, else the last message.
let busy: Vec<&str> = app
.background
.jobs
.iter()
.filter(|j| !j.label.is_empty() && !j.cancelled())
.map(|j| j.label.as_str())
.chain(app.opening.pending.iter().map(|p| p.label.as_str()))
.collect();
if let Some(job) = busy.first() {
let more = busy.len() - 1;
let label = crate::i18n::msg(job);
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
} else if !app.ui.status.is_empty() {
// The message stays English in `ui.status` (agents and tests read it).
let message = crate::i18n::msg(&app.ui.status);
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
}
});
status_readouts(app, ui, &t);
});
});
}
/// The most recently measured status widths, scoped to this UI and display settings.
struct StatusReadoutWidths {
key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32),
values: [f32; 4],
}
/// Reuse sample-string measurements until language, document units or display scale changes.
/// Keep a single entry per UI so repeated DPI/zoom changes cannot grow the cache indefinitely.
fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc::Unit, t: &Tokens) -> Arc<StatusReadoutWidths> {
let id = ui.id().with("status-readout-widths");
let key = (lang, unit, ui.ctx().pixels_per_point());
if let Some(cached) = ui.data(|data| data.get_temp::<Arc<StatusReadoutWidths>>(id))
&& cached.key == key
{
return cached;
}
// Text layout also accesses egui state, so measure outside the ui.data lock.
let mono = theme::mono(10.5);
let proportional = egui::FontId::proportional(10.5);
let render = crate::i18n::tr_ctx(lang, "diagnostics", "render");
let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui");
let measure = |text, font: &egui::FontId| ui.painter().layout_no_wrap(text, font.clone(), t.text).size().x.ceil() + 4.0;
// Reserve a readable numeric range using every digit in the active font. These samples
// depend on the language/units, never the live values: timings cannot move field boundaries.
let timing_width = |label| (0..=9).map(|d| measure(format!("{label} {d}{d}{d}{d}.{d} ms"), &proportional)).fold(0.0_f32, f32::max);
let render_width = timing_width(render);
let ui_width = timing_width(ui_name);
let fps_width = (0..=9).map(|d| measure(format!("{d}{d}{d}{d} fps"), &proportional)).fold(0.0_f32, f32::max);
// Each axis shows at least -9999.99..9999.99 in the selected unit without elision.
let axis_width = (0..=9).map(|d| measure(format!("X: -{d}{d}{d}{d}.{d}{d} {}", unit.suffix()), &mono)).fold(100.0_f32, f32::max);
let widths = Arc::new(StatusReadoutWidths { key, values: [render_width, ui_width, fps_width, axis_width] });
ui.data_mut(|data| data.insert_temp(id, Arc::clone(&widths)));
widths
}
/// Allocate the remaining row once, then paint each field inside its own bounded rectangle.
/// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot.
fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
const MESSAGE_MIN: f32 = 180.0;
let mono = theme::mono(10.5);
let proportional = egui::FontId::proportional(10.5);
let unit = app.session.general_unit();
let lang = crate::i18n::current();
let render = crate::i18n::tr_ctx(lang, "diagnostics", "render");
let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui");
let widths = status_readout_widths(ui, lang, unit, t);
let [render_width, ui_width, fps_width, axis_width] = widths.values;
let coord_width = 2.0 * axis_width;
let perf_width = render_width + ui_width + fps_width + 12.0;
let point_id = ui.id().with(("status-hover-point", app.session.active().map(|st| st.uid)));
if let Some(p) = app.hover_doc {
ui.data_mut(|data| data.insert_temp(point_id, p));
}
let gap = ui.spacing().item_spacing.x;
let size = vec2(ui.available_width().max(0.0), ui.available_height().max(0.0));
let (area, _) = ui.allocate_exact_size(size, Sense::hover());
let coordinates = area.width() >= MESSAGE_MIN + coord_width + gap;
let diagnostics = area.width() >= MESSAGE_MIN + coord_width + perf_width + 2.0 * gap;
let mut right = area.right();
if diagnostics {
let perf = egui::Rect::from_min_max(egui::pos2(right - perf_width, area.top()), area.right_bottom());
let mut x = perf.left();
for (id, width, text) in [
("render", render_width, format!("{render} {:.1} ms", app.perf.render_ms)),
("ui", ui_width, format!("{ui_name} {:.1} ms", app.perf.frame_ms)),
("fps", fps_width, format!("{:.0} fps", app.perf.fps)),
] {
let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(width, area.height()));
status_field(ui, id, rect, text, proportional.clone(), t.text_disabled);
x += width + 6.0;
}
right = perf.left() - gap;
}
if coordinates {
let left = right - coord_width;
// Keep the coordinate slot even without a hover point, so entering/leaving the canvas
// cannot move or re-truncate the message. X and Y have separate, stable origins.
let coord_rect = egui::Rect::from_min_max(egui::pos2(left, area.top()), egui::pos2(right, area.bottom()));
// The canvas clears hover_doc when the pointer leaves. While inspecting a readout,
// retain its last value so its tooltip can show the complete coordinate.
let inspecting = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| coord_rect.contains(p)) && ui.rect_contains_pointer(coord_rect);
let point = app.hover_doc.or_else(|| inspecting.then(|| ui.data(|data| data.get_temp::<vectorcraft_geom::Point>(point_id))).flatten());
if let Some(p) = point {
for (id, x, value) in [("X", left, p.x), ("Y", left + axis_width, p.y)] {
let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(axis_width, area.height()));
status_field(ui, id, rect, format!("{id}: {}", unit.readout(value)), mono.clone(), t.text_dim);
}
}
right = left - gap;
}
let busy: Vec<&str> = app
.background
.jobs
.iter()
.filter(|j| !j.label.is_empty() && !j.cancelled())
.map(|j| j.label.as_str())
.chain(app.opening.pending.iter().map(|p| p.label.as_str()))
.collect();
// Reserve the spinner's space while idle too: starting a background job changes the text,
// not its origin. Messages take priority over optional readouts in a narrow window.
let message = if let Some(job) = busy.first() {
let spinner = egui::Rect::from_center_size(egui::pos2(area.left() + 6.0, area.center().y), vec2(12.0, 12.0));
ui.put(spinner, egui::Spinner::new().size(12.0).color(t.accent));
let label = crate::i18n::msg(job);
let more = busy.len() - 1;
if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }
} else {
crate::i18n::msg(&app.ui.status)
};
if !message.is_empty() {
let rect = egui::Rect::from_min_max(egui::pos2((area.left() + 18.0).min(right), area.top()), egui::pos2(right, area.bottom()));
status_field(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text);
}
}
/// A field never grows with its text. Clipping also bounds over-wide glyphs; hover and access
/// information retain the complete value when its on-screen label is truncated.
fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32) {
if rect.width() <= 0.0 || rect.height() <= 0.0 {
return;
}
let galley = egui::WidgetText::from(egui::RichText::new(&text).font(font)).into_galley(
ui,
Some(egui::TextWrapMode::Truncate),
rect.width(),
egui::TextStyle::Body,
);
let at = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
let elided = galley.elided;
ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color);
let response = ui.interact(rect, ui.id().with(("status-field", id)), Sense::hover());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &text));
if elided {
response.on_hover_text(text);
}
}
/// Contextual hint for the active tool: segments of (text, bold). Keys in bold segments are
/// written as shortcuts are ("Alt+Click"); [`hint_segments`] names them for the platform.
/// The hint of the Zoom tool with Performance › Animated Zoom on.
@@ -1026,6 +1126,10 @@ mod tests {
assert_eq!(super::first_tab_shown(&widths, None, 350.0), 0);
assert_eq!(super::first_tab_shown(&widths, Some(4), 50.0), 4, "a tab wider than the room still shows");
}
use std::sync::Arc;
use super::{StatusReadoutWidths, status_readouts};
use crate::theme::Tokens;
use serde_json::json;
use vectorcraft_engine::Session;
@@ -1050,6 +1154,326 @@ mod tests {
}
}
#[derive(Debug)]
struct StatusText {
text: String,
at: egui::Pos2,
clip: egui::Rect,
bounds: egui::Rect,
elided: bool,
}
/// Collect status-bar text layout from a headless frame without pointer events.
fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32) -> Vec<StatusText> {
status_readout_frame_events(app, ctx, width, vec![])
}
/// Collect status-bar text, positions, clips and elision state with explicit pointer events.
fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec<egui::Event>) -> Vec<StatusText> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) => Some(StatusText {
text: t.galley.job.text.clone(),
at: t.pos,
clip: s.clip_rect,
bounds: egui::Rect::from_min_size(t.pos, t.galley.size()),
elided: t.galley.elided,
}),
_ => None,
})
.collect()
}
/// Find a required text record by prefix, failing the test when it is absent.
fn status_text<'a>(texts: &'a [StatusText], prefix: &str) -> &'a StatusText {
texts.iter().find(|t| t.text.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix:?}: {texts:?}"))
}
/// Prepare an English test document with the app fonts and a status message.
fn status_readout_app() -> (crate::VectorcraftApp, egui::Context) {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.ui.status = "Saved recovery data for Untitled-1".to_owned();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Brightness::Dark);
(app, ctx)
}
/// Draw the production readouts and observe the cached measurement object across frames.
fn status_width_cache_frame(app: &crate::VectorcraftApp, ctx: &egui::Context, width: f32, ppp: f32) -> Arc<StatusReadoutWidths> {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0));
let mut input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
let mut cache = None;
let mut output = ctx.run_ui(input, |ui| {
let t = Tokens::get(ui.ctx());
status_readouts(app, ui, &t);
cache = ui.data(|data| data.get_temp::<Arc<StatusReadoutWidths>>(ui.id().with("status-readout-widths")));
});
output.textures_delta.clear();
cache.expect("production status readouts populated their cache")
}
/// Live values, hover state and window width reuse the same measurement object.
#[test]
fn status_width_measurements_are_reused_across_frames() {
let (mut app, ctx) = status_readout_app();
let first = status_width_cache_frame(&app, &ctx, 1400.0, 1.0);
for width in [1400.0_f32, 500.0, 2200.0] {
app.perf = crate::Perf { render_ms: f64::from(width), frame_ms: f64::from(width) / 3.0, fps: 120.0 };
app.hover_doc = Some(vectorcraft_geom::Point::new(-width as f64, width as f64));
let cached = status_width_cache_frame(&app, &ctx, width, 1.0);
assert!(Arc::ptr_eq(&first, &cached), "live state or window size remeasured the samples");
}
app.hover_doc = None;
assert!(Arc::ptr_eq(&first, &status_width_cache_frame(&app, &ctx, 1400.0, 1.0)));
}
/// Each independent cache-key component refreshes widths, then subsequent frames reuse them.
#[test]
fn status_width_measurements_refresh_for_language_units_and_dpi() {
let (mut app, ctx) = status_readout_app();
let mut previous = status_width_cache_frame(&app, &ctx, 4000.0, 1.0);
let french = crate::i18n::Lang::from_code("fr").unwrap();
for (lang, unit, ppp) in [
(french, vectorcraft_doc::Unit::Points, 1.0),
(french, vectorcraft_doc::Unit::Millimeters, 1.0),
(french, vectorcraft_doc::Unit::Millimeters, 1.5),
(crate::i18n::Lang::EN, vectorcraft_doc::Unit::Millimeters, 1.5),
(crate::i18n::Lang::EN, vectorcraft_doc::Unit::Points, 1.5),
(crate::i18n::Lang::EN, vectorcraft_doc::Unit::Points, 1.0),
] {
crate::i18n::set_current(lang);
app.session.execute("document.setUnits", &json!({"units": unit.key()})).unwrap();
let fresh = status_width_cache_frame(&app, &ctx, 4000.0, ppp);
assert_eq!(fresh.key, (lang, unit, ppp));
assert!(!Arc::ptr_eq(&previous, &fresh), "changed language/units/DPI reused stale widths");
assert!(fresh.values.iter().all(|w| w.is_finite() && *w > 0.0));
assert!(Arc::ptr_eq(&fresh, &status_width_cache_frame(&app, &ctx, 4000.0, ppp)));
previous = fresh;
}
}
/// These assertions require every target to be present, and cover the old padding limits.
#[test]
fn the_status_bar_stands_still_while_its_readouts_change() {
let (mut app, ctx) = status_readout_app();
let mut baseline = None;
let mut message_baseline = None;
for (render, frame, fps, x, y) in [
(233.4, 4.9, 80.0, -662.25, 1647.9),
(9.8, 1.0, 120.0, 7.0, 5.0),
(100000.0, 10000.0, 1000.0, -1000.0, 100000.0),
(99999.9, 9999.9, 999.0, -999.99, 9999.99),
] {
app.perf = crate::Perf { render_ms: render, frame_ms: frame, fps };
app.hover_doc = Some(vectorcraft_geom::Point::new(x, y));
let texts = status_readout_frame(&mut app, &ctx, 1400.0);
let message = status_text(&texts, "Saved recovery data");
let extent = (message.at, message.clip, message.bounds, message.elided);
if let Some(first) = message_baseline {
assert_eq!(extent, first, "message extent changed with readout values");
} else {
message_baseline = Some(extent);
}
for (prefix, value) in [
("render", format!("{render:.1} ms")),
("ui ", format!("{frame:.1} ms")),
("X:", app.session.general_unit().readout(x)),
("Y:", app.session.general_unit().readout(y)),
] {
let text = status_text(&texts, prefix);
assert!(text.text.contains(&value), "{text:?} must contain {value:?}");
assert!(text.bounds.width() <= text.clip.width() + 0.01, "field overflow: {text:?}");
}
let fps_text = format!("{fps:.0} fps");
let fps_label = status_text(&texts, &fps_text);
assert!(fps_label.bounds.width() <= fps_label.clip.width() + 0.01);
let positions = ["Saved recovery data", "render", "ui ", "X:", "Y:", fps_text.as_str()].map(|p| status_text(&texts, p).at);
if let Some(first) = baseline {
assert_eq!(positions, first, "readout origins changed");
} else {
baseline = Some(positions);
}
}
app.perf.render_ms = 1000000.0;
let tree = crate::widgets::accessibility_tests::tree(|ui| super::status_bar(&mut app, ui));
assert!(
tree.iter().any(|n| n.role() == egui::accesskit::Role::Label && n.value() == Some("render 1000000.0 ms")),
"screen readers receive the complete value"
);
}
/// Canvas hover entry and exit keep the status message at the same origin.
#[test]
fn status_messages_keep_their_origin_when_the_pointer_enters_and_leaves() {
let (mut app, ctx) = status_readout_app();
let mut first = None;
for hover in [None, Some(vectorcraft_geom::Point::new(7.0, 5.0)), None] {
app.hover_doc = hover;
let texts = status_readout_frame(&mut app, &ctx, 1400.0);
let message = status_text(&texts, "Saved recovery data");
assert!(!message.elided);
let position = (message.at, message.clip);
if let Some(first) = first {
assert_eq!(position, first);
} else {
first = Some(position);
}
assert_eq!(texts.iter().any(|t| t.text.starts_with("X:")), hover.is_some());
}
}
/// Hovering a coordinate preserves its document-scoped value for inspection.
#[test]
fn hovering_a_coordinate_retains_its_full_value_for_inspection() {
let (mut app, ctx) = status_readout_app();
app.hover_doc = Some(vectorcraft_geom::Point::new(1e12, -1e12));
let first = status_readout_frame(&mut app, &ctx, 1400.0);
let x = status_text(&first, "X:");
assert!(x.elided);
let expected = x.text.clone();
app.hover_doc = None;
let inspect = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(x.clip.center())]);
assert_eq!(status_text(&inspect, "X:").text, expected);
let gone = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerGone]);
assert!(!gone.iter().any(|t| t.text.starts_with("X:")));
// A different document must not inherit the previous document's coordinate.
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let other = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(x.clip.center())]);
assert!(!other.iter().any(|t| t.text.starts_with("X:")));
}
/// Narrow windows hide optional readouts while keeping the status message visible.
#[test]
fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() {
let (mut app, ctx) = status_readout_app();
app.hover_doc = Some(vectorcraft_geom::Point::new(7.0, 5.0));
let mut origin = None;
for (width, coords, perf) in [(1400.0, true, true), (1000.0, true, false), (800.0, false, false)] {
let texts = status_readout_frame(&mut app, &ctx, width);
let message = status_text(&texts, "Saved recovery data");
assert!(!message.elided, "{width}: message was truncated: {message:?}");
if let Some(origin) = origin {
assert_eq!(message.at, origin);
} else {
origin = Some(message.at);
}
assert_eq!(texts.iter().any(|t| t.text.starts_with("X:")), coords, "{width}");
assert_eq!(texts.iter().any(|t| t.text.starts_with("render")), perf, "{width}");
let tool = status_text(&texts, "Selection");
assert!(message.clip.left() >= tool.bounds.right(), "readouts must not overlap navigation: {texts:?}");
for field in texts.iter().filter(|t| t.text.starts_with("X:") || t.text.starts_with("Y:") || t.text.starts_with("render")) {
assert!(field.clip.left() >= tool.bounds.right());
assert!(!field.clip.intersects(message.clip));
}
}
// Still narrower bars may truncate a long message but must not draw over navigation.
let texts = status_readout_frame(&mut app, &ctx, 600.0);
let message = status_text(&texts, "Saved recovery data");
assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right());
}
/// Busy-job transitions preserve message placement, labels and accessibility.
#[test]
fn background_activity_keeps_the_status_message_origin() {
let (mut app, ctx) = status_readout_app();
let before = status_readout_frame(&mut app, &ctx, 1400.0);
let before = status_text(&before, "Saved recovery data");
let (tx, rx) = std::sync::mpsc::channel();
app.services.write_shared = Some(std::sync::Arc::new(|_, _| Ok(())));
crate::background::run(
&mut app,
true,
"Saving test.vectorcraft".to_owned(),
None,
move |_| {
rx.recv().unwrap();
Ok(json!({}))
},
|app, result| {
app.status("Saved recovery data for Untitled-1");
result
},
)
.unwrap();
let busy = status_readout_frame(&mut app, &ctx, 1400.0);
let message = status_text(&busy, "Saving test.vectorcraft");
assert_eq!((before.at, before.clip), (message.at, message.clip));
crate::background::run(&mut app, true, "Exporting test.svg".to_owned(), None, |_| Ok(json!({})), |_, result| result).unwrap();
let several = status_readout_frame(&mut app, &ctx, 1400.0);
let message = status_text(&several, "Saving test.vectorcraft");
assert!(message.text.ends_with("(+1)"));
assert_eq!((before.at, before.clip), (message.at, message.clip));
let tree = crate::widgets::accessibility_tests::tree(|ui| super::status_bar(&mut app, ui));
assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::ProgressIndicator), "the busy spinner remains accessible");
tx.send(()).unwrap();
crate::background::wait_all(&mut app);
let after = status_readout_frame(&mut app, &ctx, 1400.0);
let after = status_text(&after, "Saved recovery data");
assert_eq!((before.at, before.clip), (after.at, after.clip));
}
/// Ordinary signed coordinates fit without elision in every supported unit.
#[test]
fn ordinary_coordinates_are_readable_in_every_unit() {
let (mut app, ctx) = status_readout_app();
for unit in vectorcraft_doc::Unit::ALL {
app.session.execute("document.setUnits", &json!({"units": unit.key()})).unwrap();
for (x, y) in [(-1234.56, 1234.56), (-9999.99, 9999.99), (9999.99, -9999.99)] {
let (x, y) = (unit.to_pt(x), unit.to_pt(y));
app.hover_doc = Some(vectorcraft_geom::Point::new(x, y));
let texts = status_readout_frame(&mut app, &ctx, 1400.0);
for (axis, value) in [("X:", x), ("Y:", y)] {
let field = status_text(&texts, axis);
assert_eq!(field.text, format!("{axis} {}", unit.readout(value)));
assert!(!field.elided, "{unit:?}: {field:?}");
assert!(field.clip.contains_rect(field.bounds), "{unit:?}: {field:?}");
}
}
}
}
/// Localized diagnostics remain readable without moving neighboring fields.
#[test]
fn localized_diagnostics_are_readable_and_keep_their_field_bounds() {
let (mut app, ctx) = status_readout_app();
for lang in crate::i18n::Lang::all() {
crate::i18n::set_current(lang);
let render = crate::i18n::tr_ctx(lang, "diagnostics", "render");
let ui_name = crate::i18n::tr_ctx(lang, "diagnostics", "ui");
let mut baseline = None;
for (render_ms, frame_ms, fps, readable) in [(233.4, 4.9, 80.0, true), (9999.9, 9999.9, 9999.0, true), (1e12, 1e12, 1e12, false)] {
app.perf = crate::Perf { render_ms, frame_ms, fps };
let texts = status_readout_frame(&mut app, &ctx, 4000.0);
let fields = [format!("{render} {render_ms:.1} ms"), format!("{ui_name} {frame_ms:.1} ms"), format!("{fps:.0} fps")];
let bounds = fields.map(|value| {
let field = status_text(&texts, &value);
assert_eq!(field.text, value);
assert_eq!(!field.elided, readable, "{}: {field:?}", lang.code());
assert!(field.bounds.width() <= field.clip.width() + 0.01, "{}: {field:?}", lang.code());
(field.at, field.clip)
});
if let Some(first) = baseline {
assert_eq!(bounds, first, "{}: field allocation changed", lang.code());
} else {
baseline = Some(bounds);
}
}
// Even long translations must yield their whole diagnostics group in a narrow
// window rather than overlapping the message or navigation controls.
let texts = status_readout_frame(&mut app, &ctx, 800.0);
assert!(!texts.iter().any(|t| t.text == format!("{render} {:.1} ms", app.perf.render_ms)), "{}", lang.code());
}
crate::i18n::set_current(crate::i18n::Lang::EN);
}
/// A document tab's × sits where the platform puts it (#673): after the title on Windows,
/// Linux and the web, before it on macOS; clicking it closes that document.
#[test]
@@ -1101,6 +1525,7 @@ mod tests {
r
}
/// Artboard navigation updates the active artboard and fitted view together.
#[test]
fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() {
use vectorcraft_geom::Point;
@@ -1111,6 +1536,7 @@ mod tests {
app.run("artboard.new", json!({})).unwrap();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0)));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let buttons = navigator(&mut app, &ctx, vec![]);
assert_eq!(buttons.len(), 4);
let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| {
@@ -1207,6 +1633,7 @@ mod tests {
app.run("view.goToArtboard", json!({"index": 1})).unwrap();
let first = app.session.active().unwrap().doc.artboards[0].rect.center();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = status_texts(&mut app, &ctx, vec![]);
let field = texts.iter().find(|(s, _)| s == "2 Back").map(|(_, r)| *r).expect("the field shows artboard 2's number and name");
assert!(!texts.iter().any(|(s, _)| s == "Front"), "the list is closed: {texts:?}");
@@ -1237,6 +1664,7 @@ mod tests {
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0)));
app.run("view.goToArtboard", json!({"index": 699})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let rows = |texts: &[(String, egui::Rect)]| texts.iter().filter(|(s, _)| s.starts_with("Artboard ")).cloned().collect::<Vec<_>>();
let texts = status_texts(&mut app, &ctx, vec![]);
let field = texts.iter().find(|(s, _)| s == "700 Artboard 700").map(|(_, r)| *r).expect("the field");
@@ -1318,6 +1746,7 @@ mod tests {
let selection = st.selection.clone();
let undo = st.history.undo.len();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
artboard_navigation_next(&mut app, &ctx);
@@ -1392,6 +1821,7 @@ mod tests {
artboard_navigation_delete(None);
}
/// Navigating artboards preserves other tools, their selections and undo history.
#[test]
fn artboard_navigation_preserves_other_tools_selection_and_history() {
for tool in ["selection", "rectangle"] {
@@ -1407,7 +1837,9 @@ mod tests {
assert_eq!(history.1, 1);
let options = app.session.tool_options();
artboard_navigation_next(&mut app, &egui::Context::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
artboard_navigation_next(&mut app, &ctx);
assert_eq!(app.view().unwrap().artboard, 1);
assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center());
+3 -1
View File
@@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
const GLYPHS: [Cursor; 75] = [
const GLYPHS: [Cursor; 76] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -454,6 +454,7 @@ const GLYPHS: [Cursor; 75] = [
Cursor::Blend,
Cursor::BlendObject,
Cursor::BlendAnchor,
Cursor::ObjectsOnPath,
Cursor::PathBracket,
Cursor::TypeWidget,
Cursor::LoadedText,
@@ -797,6 +798,7 @@ mod tests {
assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}");
}
assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors");
assert!(rasterize(Cursor::Busy, 1.0).is_none());
assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor");
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
@@ -1,8 +1,9 @@
//! Layers panel › Panel Options…: Show Layers Only, the row size and which rows show thumbnails.
//! Layers panel › Panel Options…: Show Layers Only, Locate Selected Objects, the row size and
//! which rows show thumbnails.
//!
//! Fields: `layersOnly`, `rowSize` (`small`, `medium`, `large` or `other`), `otherSize` (points,
//! 12–100, for `other`), `thumbLayers`, `thumbGroups` and `thumbObjects`. OK applies them to the
//! panel (kept with the UI state).
//! Fields: `layersOnly`, `locateSelected`, `rowSize` (`small`, `medium`, `large` or `other`),
//! `otherSize` (points, 12–100, for `other`), `thumbLayers`, `thumbGroups` and `thumbObjects`. OK
//! applies them to the panel (kept with the UI state).
use serde_json::{Value, json};
@@ -25,7 +26,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let o = &app.ui.layers_panel;
let size = SIZES.iter().find(|s| (s.2 - o.row()).abs() < 0.01).map_or("other", |s| s.0);
let fields = json!({
"layersOnly": o.layers_only, "rowSize": size, "otherSize": o.row(),
"layersOnly": o.layers_only, "locateSelected": o.locate_selected, "rowSize": size, "otherSize": o.row(),
"thumbLayers": o.thumb_layers, "thumbGroups": o.thumb_groups, "thumbObjects": o.thumb_objects,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
@@ -41,6 +42,7 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) {
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
check(ui, d, "layersOnly", tl!("Show Layers Only"));
check(ui, d, "locateSelected", tl!("Locate Selected Objects"));
ui.add_space(8.0);
grid(ui, |ui| {
label(ui, tl!("Row Size:"));
@@ -73,6 +75,7 @@ pub(crate) fn options(d: &Dialog) -> PanelOptions {
let row_size = SIZES.iter().find(|s| s.0 == size).map_or_else(|| (d.f64("otherSize", f64::from(ROW_MEDIUM)) as f32).clamp(12.0, 100.0), |s| s.2);
PanelOptions {
layers_only: d.bool("layersOnly"),
locate_selected: d.bool("locateSelected"),
row_size: if row_size.is_finite() { row_size } else { ROW_MEDIUM },
thumb_layers: d.bool("thumbLayers"),
thumb_groups: d.bool("thumbGroups"),
@@ -115,4 +118,23 @@ mod tests {
super::super::confirm(&mut app).unwrap();
assert_eq!(app.ui.layers_panel.row(), ROW_LARGE);
}
/// Locate Selected Objects is off by default and in options saved without it; OK turns it on
/// and keeps it with the UI state, and the dialog opens on it again.
#[test]
fn locate_selected_objects_is_off_by_default_and_kept_with_the_ui_state() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.run("ui.layersPanelOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.fields.get("locateSelected"), Some(&json!(false)));
d.fields.insert("locateSelected".into(), json!(true));
super::super::confirm(&mut app).unwrap();
let back: crate::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap();
assert!(back.layers_panel.locate_selected, "kept with the UI state");
app.run("ui.layersPanelOptions", json!({})).unwrap();
assert!(app.ui.dialog.as_ref().unwrap().bool("locateSelected"));
let old: crate::UiState = serde_json::from_value(json!({"layers_panel": {"layersOnly": true}})).unwrap();
assert!(old.layers_panel.layers_only && !old.layers_panel.locate_selected, "options saved without it");
}
}
@@ -0,0 +1,142 @@
//! Live Paint Selection Tool Options (double-click the Live Paint Selection tool): what a click
//! with the tool selects and how the face or edge under the pointer highlights. The options last
//! across tool switches and are saved with the preferences. OK sets them with
//! `tool.setOption {tool: "livePaintSelection", values}`.
//!
//! Fields: `selectFills` and `selectStrokes` (the faces and the edges of Live Paint groups),
//! `color` (the highlight color: `#rrggbb`, or a layer color's name such as "Red") and `width`
//! (the highlight's width in screen pixels).
use serde_json::{Map, Value, json};
use vectorcraft_color::Color;
use vectorcraft_doc::LAYER_COLORS;
use vectorcraft_tools::builder::LP_SELECT_WIDTH_RANGE;
use super::tile_edge_color::resolve;
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Live Paint Selection Tool Options.
pub const KIND: &str = "livePaintSelectionOptions";
/// The tool the dialog serves.
const TOOL: &str = "livePaintSelection";
/// The options the dialog edits.
const KEYS: [&str; 4] = ["selectFills", "selectStrokes", "color", "width"];
/// Width of the label column.
const LABEL_W: f32 = 60.0;
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 300.0, ..DialogSpec::FORM };
fn heading(_: &Dialog) -> String {
let label = vectorcraft_tools::tool_info(TOOL).map_or("", |t| t.label);
crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))])
}
/// Open the Live Paint Selection Tool Options with the tool's current values.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let o = app.session.tool_options_of(TOOL);
let fields: Map<String, Value> = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect();
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(json!({ "dialog": KIND }))
}
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::check(ui, d, "selectFills", tl!("Select Fills"));
form::check(ui, d, "selectStrokes", tl!("Select Strokes"));
ui.add_space(6.0);
let color = resolve(&d.str("color")).unwrap_or(Color::BLACK);
let [r, g, b, _] = color.to_rgba8(1.0);
let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]);
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
widgets::label_row(ui, tl!("Color:"), LABEL_W, |ui| {
if let Some((_, [r, g, b])) =
widgets::dropdown(ui, "lp-select-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0).and_then(|i| LAYER_COLORS.get(i))
{
d.fields.insert("color".into(), json!(Color::rgb8(*r, *g, *b).to_hex()));
}
// The color button sets any other color; the dropdown then shows Custom.
let mut rgb = [r, g, b];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("color".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex()));
}
});
let (min, max) = LP_SELECT_WIDTH_RANGE;
let v = d.f64("width", min);
widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| {
if let Some(x) = widgets::plain_field(ui, "lp-select-width", v, "px", 0, 72.0) {
d.fields.insert("width".into(), json!(x.round().clamp(min, max)));
}
});
false
}
/// OK: set the tool's options.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let values: Map<String, Value> = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": TOOL, "values": values }))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_tools::{Overlay, PointerEvent, PointerKind};
/// The labels the open dialog paints.
fn painted(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn double_clicking_the_tool_opens_its_options_and_ok_sets_them() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
// Two squares side by side, made a Live Paint group: two faces and the edge between them.
for x in [0, 100] {
app.run("shape.rectangle", json!({"x": 50 + x, "y": 50, "width": 100, "height": 100})).unwrap();
}
app.run("select.all", json!({})).unwrap();
app.run("livePaint.make", json!({})).unwrap();
app.run("select.set", json!({"ids": []})).unwrap();
app.select_tool(TOOL);
let click = |app: &mut VectorcraftApp, x: f64| {
let view = app.view_info();
crate::canvas::dispatch(app, &PointerEvent::new(PointerKind::Down, x, 100.0), view);
crate::canvas::dispatch(app, &PointerEvent::new(PointerKind::Up, x, 100.0), view);
app.session.doc().unwrap().selection.objects.len()
};
assert_eq!(click(&mut app, 100.0), 1, "a face is selected by default");
app.run("tool.options", json!({"tool": TOOL})).unwrap();
let text = painted(&mut app);
for label in ["Live Paint Selection Tool Options", "Select Fills", "Select Strokes", "Color:", "Width:"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.kind, KIND);
assert_eq!((d.bool("selectFills"), d.bool("selectStrokes"), d.str("color"), d.f64("width", 0.0)), (true, true, "#ff2a2a".to_string(), 4.0));
d.fields.insert("selectFills".into(), json!(false));
d.fields.insert("color".into(), json!("Green"));
d.fields.insert("width".into(), json!(8));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
// The tool takes them: a click inside a face selects nothing, and the highlight shows the edge
// in the new color and width.
assert_eq!(click(&mut app, 100.0), 0);
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Move, 151.0, 100.0), view);
let ov = app.session.overlays(view);
assert!(ov.iter().any(|o| matches!(o, Overlay::Path { color: [0x4f, 0xff, 0x4f], width: 8.0, .. })), "{ov:?}");
// They last while another tool is chosen, and the dialog opens on them again.
app.select_tool("selection");
let o = app.session.tool_options_of(TOOL);
assert_eq!((o["selectFills"].as_bool(), o["color"].as_str(), o["width"].as_f64()), (Some(false), Some("#4fff4f"), Some(8.0)));
app.run("tool.options", json!({"tool": TOOL})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.bool("selectFills"), d.str("color")), (false, "#4fff4f".to_string()));
}
}
+2
View File
@@ -51,6 +51,7 @@ pub mod layer_options;
pub mod layers_panel_options;
pub mod liquify;
pub mod live_paint_gaps;
pub mod live_paint_selection;
pub mod missing_fonts;
pub mod missing_links;
pub(crate) mod modal;
@@ -320,6 +321,7 @@ registry! {
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
LivePaintGaps: [live_paint_gaps::KIND] => live_paint_gaps::SPEC,
LivePaintSelectionOptions: [live_paint_selection::KIND] => live_paint_selection::SPEC,
BrushOptions: [brush_options::KIND] => brush_options::SPEC,
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
+54 -3
View File
@@ -16,6 +16,55 @@ const ICON_COL: f32 = 38.0;
const HEADER: f32 = 14.0;
/// The tabbed group's width until the dock has been laid out (its default size).
const DOCK_WIDTH: f32 = 300.0;
pub(crate) const LEGACY_DOCK_PANEL: &str = "dock";
pub(crate) const SHARED_DOCK_PANEL: &str = "shared-panel-dock";
const LEGACY_DOCK_MIN: f32 = 230.0;
const LEGACY_DOCK_MAX: f32 = 520.0;
const SHARED_DOCK_MIN: f32 = 220.0;
const SHARED_DOCK_MAX: f32 = 720.0;
pub(crate) fn clamp_legacy_dock_width(width: f32) -> f32 {
width.clamp(LEGACY_DOCK_MIN, LEGACY_DOCK_MAX)
}
pub(crate) fn clamp_shared_dock_width(width: f32) -> f32 {
width.clamp(SHARED_DOCK_MIN, SHARED_DOCK_MAX)
}
fn seed_dock_panel_width(ctx: &egui::Context, panel: &str, width: f32) {
let screen = ctx.content_rect();
let w = width.max(1.0);
let outer_rect = egui::Rect::from_min_size(egui::pos2(screen.right() - w, screen.top()), egui::vec2(w, screen.height()));
let state = egui::containers::panel::PanelState { outer_rect };
ctx.data_mut(|data| data.insert_persisted(egui::Id::new(panel), state));
}
pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str, clamp: fn(f32) -> f32) {
if !app.ui.dock_width_pending {
return;
}
if let Some(width) = app.ui.dock_width.filter(|w| w.is_finite() && *w > 0.0) {
seed_dock_panel_width(ctx, panel, clamp(width));
}
app.ui.dock_width_pending = false;
}
pub(crate) fn note_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str) {
if let Some(state) = egui::containers::panel::PanelState::load(ctx, egui::Id::new(panel)) {
let width = state.size().x;
if width.is_finite() && width > 0.0 {
app.ui.dock_width = Some(width);
}
}
}
pub(crate) fn legacy_dock_default_width(ui: &crate::state::UiState) -> f32 {
ui.dock_width.filter(|w| w.is_finite()).map(clamp_legacy_dock_width).unwrap_or(DOCK_WIDTH)
}
pub(crate) fn shared_dock_default_width(ui: &crate::state::UiState) -> f32 {
ui.dock_width.filter(|w| w.is_finite()).map(clamp_shared_dock_width).unwrap_or(310.0)
}
/// Top of a popped-out panel.
const FLYOUT_TOP: f32 = 110.0;
@@ -81,14 +130,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width);
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| {
egui::Panel::right("dock")
egui::Panel::right(LEGACY_DOCK_PANEL)
.resizable(true)
.default_size(DOCK_WIDTH)
.size_range(230.0..=520.0)
.default_size(legacy_dock_default_width(&app.ui))
.size_range(LEGACY_DOCK_MIN..=LEGACY_DOCK_MAX)
.frame(egui::Frame::NONE)
.show(ui, |ui| {
crate::theme::card(ui, &t, egui::Margin { left: 0, right: 6, top: 6, bottom: 6 }, |ui| {
@@ -114,6 +164,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
.response
.rect
});
note_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL);
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
// collapsed group's panels head it, under the « that expands them again. Floating panels leave
// it.
+3
View File
@@ -354,6 +354,7 @@ fn heading(id: &str) -> &'static str {
_ if id.starts_with("perspective") => "Perspective",
"artboard" => "Artboards",
"slice" | "sliceSelection" => "Slices",
"objectsOnPath" => "Modify",
_ => "More",
}
}
@@ -742,6 +743,8 @@ mod tests {
assert_eq!(under("pencil"), Some("Draw"));
assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil");
assert_eq!(under("rectangle"), Some("Shapes"));
assert_eq!(under("objectsOnPath"), Some("Modify"), "framed with the Blend tool");
assert_eq!(heading("objectsOnPath"), "Modify", "though not in the Basic toolbar");
}
/// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with
+11
View File
@@ -1174,6 +1174,8 @@
Large Gaps Големи пролуки
Gap Preview Color: Цвят за преглед на пролуките:
Close Gaps With Paths Затваряне на пролуките с пътища
Select Fills Избиране на запълвания
Select Strokes Избиране на щрихове
Gaussian Blur… Гаусово размазване…
General Общи
General › Placement › X Общи › Разположение › X
@@ -1370,6 +1372,12 @@
Release Blend Освобождаване на преливане
Release Envelope Освобождаване на обвивка
Release Intertwine Освобождаване на преплитане
Objects on Path Обекти по пътя
Attach Прикрепяне
Objects on Path Options Опции за обекти по пътя
Objects on Path Tool Инструмент „Обекти по пътя“
Attach Objects on Path Прикрепяне на обекти към пътя
Expand Objects on Path Разширяване на обекти по пътя
Release Live Paint Освобождаване на динамично оцветяване
Release Repeat Освобождаване на повторение
Release Slice Освобождаване на отрязък
@@ -1672,6 +1680,7 @@
Load Text into Clipboard Зареждане на текст в клипборда
Local Libraries Локални библиотеки
Locate Object Намиране на обект
Locate Selected Objects Намиране на избраните обекти
Location Местоположение
Location: Местоположение:
Lock Заключване
@@ -2952,6 +2961,7 @@
Snap to Pixel Прилепване към пиксел
Precise Cursors Прецизни курсори
Snap to Point Прилепване към точка
Snap to Unit Прилепване към мерна единица
Snapping Tolerance Толеранс на прилепване
Social Square Post 1080×1080 Публикация за квадрат в социални мрежи 1080×1080
Soft Light Мека светлина
@@ -5178,3 +5188,4 @@ theme Appearance Външен вид
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them променливите и наборите от данни на файла не бяха прочетени (променливи: {variables}, набори от данни: {data_sets}): панелът „Променливи“ не ги показва
# Select by Color (select.color)
Select by Color Избиране по цвят
Move Anchor Point Преместване на опорна точка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -264,6 +264,8 @@
Large Gaps Velké mezery
Gap Preview Color: Barva náhledu mezer:
Close Gaps With Paths Uzavřít mezery cestami
Select Fills Vybírat výplně
Select Strokes Vybírat tahy
Image Trace Vektorizace obrazu
Image Trace (Make and Expand) Vektorizace obrazu (vytvořit a rozložit)
Release Image Trace Uvolnit vektorizaci obrazu
@@ -1565,6 +1567,12 @@ lighting Light Světlo
Release Blend Uvolnit prolnutí
Release Envelope Uvolnit obálku
Release Intertwine Uvolnit propletení
Objects on Path Objekty na cestě
Attach Připojit
Objects on Path Options Možnosti objektů na cestě
Objects on Path Tool Nástroj Objekty na cestě
Attach Objects on Path Připojit objekty na cestu
Expand Objects on Path Rozložit objekty na cestě
Release Live Paint Uvolnit živou malbu
Release Repeat Uvolnit opakování
Release Slice Uvolnit řez
@@ -1905,6 +1913,7 @@ lighting Light Světlo
Right-Justified Tab Tabulátor zarovnaný doprava
Select a text object to set its tab stops. Vyberte textový objekt, abyste mohli nastavit jeho zarážky tabulátoru.
Select text Vyberte text
Snap to Unit Přichytávat k jednotkám
+ Add Scale + Přidat měřítko
Add Selected Artwork as One Asset Přidat vybranou kresbu jako jeden prvek
Export Settings Nastavení exportu
@@ -2260,6 +2269,7 @@ lighting Light Světlo
Layers Panel Options Možnosti panelu Vrstvy
Row Size: Velikost řádků:
Show Layers Only Zobrazit pouze vrstvy
Locate Selected Objects Najít vybrané objekty
Thumbnails: Miniatury:
Anchor Points Kotevní body
Brush Affects: Štětec ovlivňuje:
@@ -4398,6 +4408,7 @@ imageTrace Outline Obrys
@msg select a linked image: an embedded one has no file vyberte propojený obrázek: vložený nemá soubor
@msg select a repeat vyberte opakování
@msg select an Intertwine group vyberte skupinu propletení
@msg select an Objects on Path group vyberte skupinu objektů na cestě
@msg select a stroke with a variable width vyberte tah s proměnnou šířkou
@msg select a symbol instance vyberte instanci symbolu
@msg select an Image Trace object vyberte objekt vektorizace obrazu
@@ -5154,6 +5165,7 @@ mark-style Japanese Japonské
@msg choose a destination folder vyberte cílovou složku
@msg choose a source folder vyberte zdrojovou složku
@msg open a document with data sets otevřete dokument se sadami dat
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for ze sady dat {name} se nic nepoužilo: žádný objekt není svázán s proměnnou, pro kterou má hodnotu
@msg the action has no steps akce nemá žádné kroky
# User Interface › Accent Color
@@ -5214,3 +5226,4 @@ mark-style Japanese Japonské
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them proměnné a sady dat souboru nebyly načteny (proměnné: {variables}, sady dat: {data_sets}): panel Proměnné je nezobrazuje
# Select by Color (select.color)
Select by Color Vybrat podle barvy
Move Anchor Point Přesunout kotevní bod
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1734,6 +1734,7 @@ lighting Light Lys
Right-Justified Tab Højrejusteret tabulator
Select a text object to set its tab stops. Markér et tekstobjekt for at angive dets tabulatorstop.
Select text Markér tekst
Snap to Unit Fastgør til enhed
+ Add Scale + Tilføj skala
Add Selected Artwork as One Asset Tilføj markeret grafik som ét aktiv
Export Settings Eksportindstillinger
@@ -2080,6 +2081,7 @@ lighting Light Lys
Layers Panel Options Indstillinger for lagpanelet
Row Size: Rækkestørrelse:
Show Layers Only Vis kun lag
Locate Selected Objects Lokalisér markerede objekter
Thumbnails: Miniaturer:
Anchor Points Ankerpunkter
Brush Affects: Penslen påvirker:
@@ -5074,6 +5076,8 @@ swatch Bright Violet Kraftig violet
Chop Design Beskær design
Clear Events Ryd hændelser
Close Gaps With Paths Luk huller med stier
Select Fills Markér udfyldninger
Select Strokes Markér konturer
Column Type: Søjletype:
Each Design Represents: Hvert design repræsenterer:
Event: Hændelse:
@@ -5151,6 +5155,12 @@ swatch Bright Violet Kraftig violet
Release Blend Frigør blanding
Release Envelope Frigør konvolut
Release Intertwine Frigør sammenfletning
Objects on Path Objekter på kurve
Attach Tilknyt
Objects on Path Options Indstillinger for objekter på kurve
Objects on Path Tool Objekter på kurve-værktøj
Attach Objects on Path Tilknyt objekter til kurve
Expand Objects on Path Udvid objekter på kurve
Release Live Paint Frigør live-maling
Release Repeat Frigør gentagelse
Release Slice Frigør udsnit
@@ -5197,3 +5207,4 @@ swatch Bright Violet Kraftig violet
Justification Justering
# Select by Color (select.color)
Select by Color Markér efter farve
Move Anchor Point Flyt ankerpunkt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1192,6 +1192,8 @@ file-filter Templates Vorlagen
Large Gaps Große Lücken
Gap Preview Color: Vorschaufarbe für Lücken:
Close Gaps With Paths Lücken mit Pfaden schließen
Select Fills Flächen auswählen
Select Strokes Konturen auswählen
Gaussian Blur… Gaußscher Weichzeichner…
General Allgemein
General › Placement › X Allgemein › Platzierung › X
@@ -1398,6 +1400,12 @@ file-filter Templates Vorlagen
Release Envelope Hülle zurückwandeln
Release Image Trace Bildnachzeichner zurückwandeln
Release Intertwine Verflechtung zurückwandeln
Objects on Path Objekte auf Pfad
Attach Anhängen
Objects on Path Options Optionen für Objekte auf Pfad
Objects on Path Tool Objekte-auf-Pfad-Werkzeug
Attach Objects on Path Objekte an Pfad anhängen
Expand Objects on Path Objekte auf Pfad umwandeln
Release Live Paint „Interaktiv malen“ zurückwandeln
Release Repeat Wiederholung zurückwandeln
Release Slice Slice zurückwandeln
@@ -1721,6 +1729,7 @@ file-filter Templates Vorlagen
Load Text into Clipboard Text in die Zwischenablage laden
Local Libraries Lokale Bibliotheken
Locate Object Objekt ausfindig machen
Locate Selected Objects Ausgewählte Objekte ausfindig machen
Location Speicherort
Location: Ort:
Lock Sperren
@@ -2994,6 +3003,7 @@ effect Points Punkte
Snap to Pixel An Pixel ausrichten
Precise Cursors Präzise Cursor
Snap to Point An Punkt ausrichten
Snap to Unit An Einheit ausrichten
Snapping Tolerance Einrasttoleranz
Social Square Post 1080×1080 Quadratischer Social-Media-Beitrag 1080×1080
Soft Light Weiches Licht
@@ -4035,6 +4045,7 @@ theme Appearance Erscheinungsbild
@msg select a linked image: an embedded one has no file wählen Sie ein verknüpftes Bild aus: Ein eingebettetes hat keine Datei
@msg select a repeat wählen Sie eine Wiederholung aus
@msg select an Intertwine group wählen Sie eine Verflechtungsgruppe aus
@msg select an Objects on Path group wählen Sie eine Gruppe „Objekte auf Pfad“ aus
@msg select a stroke with a variable width wählen Sie eine Kontur mit variabler Breite aus
@msg select a symbol instance wählen Sie eine Symbolinstanz aus
@msg select an Image Trace object wählen Sie ein Bildnachzeichner-Objekt aus
@@ -4732,6 +4743,7 @@ effect Soft Weich
@msg choose a destination folder wählen Sie einen Zielordner
@msg choose a source folder wählen Sie einen Quellordner
@msg open a document with data sets öffnen Sie ein Dokument mit Datensätzen
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for aus dem Datensatz {name} wurde nichts angewendet: kein Objekt ist an eine Variable gebunden, für die er einen Wert hat
@msg the action has no steps die Aktion hat keine Schritte
# User Interface › Accent Color
@@ -4794,3 +4806,4 @@ effect Soft Weich
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them die Variablen und Datensätze der Datei wurden nicht gelesen (Variablen: {variables}, Datensätze: {data_sets}): Das Bedienfeld „Variablen“ listet sie nicht auf
# Select by Color (select.color)
Select by Color Nach Farbe auswählen
Move Anchor Point Ankerpunkt verschieben
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1139,6 +1139,8 @@
Large Gaps Μεγάλα διάκενα
Gap Preview Color: Χρώμα προεπισκόπησης διακένων:
Close Gaps With Paths Κλείσιμο διακένων με διαδρομές
Select Fills Επιλογή γεμισμάτων
Select Strokes Επιλογή περιγραμμάτων
Gaussian Blur… Θόλωση Γκάους…
General Γενικά
General › Placement › X Γενικά › Τοποθέτηση › X
@@ -1333,6 +1335,12 @@
Release Blend Αποδέσμευση ανάμειξης
Release Envelope Αποδέσμευση περιβλήματος
Release Intertwine Αποδέσμευση διαπλοκής
Objects on Path Αντικείμενα σε διαδρομή
Attach Προσάρτηση
Objects on Path Options Επιλογές αντικειμένων σε διαδρομή
Objects on Path Tool Αντικείμενα σε διαδρομή (Objects on Path Tool)
Attach Objects on Path Προσάρτηση αντικειμένων σε διαδρομή
Expand Objects on Path Επέκταση αντικειμένων σε διαδρομή
Release Live Paint Αποδέσμευση δυναμικής βαφής
Release Repeat Αποδέσμευση επανάληψης
Release Slice Αποδέσμευση τομής
@@ -1833,6 +1841,7 @@
Snap to Grid Προσκόλληση στο πλέγμα
Snap to Pixel Προσκόλληση σε εικονοστοιχείο
Snap to Point Προσκόλληση σε σημείο
Snap to Unit Προσκόλληση σε μονάδα
Soft Light Απαλό φως
Solid Συνεχές
Spacing: Απόσταση:
@@ -2457,6 +2466,7 @@ theme Appearance Εμφάνιση
Load Text into Clipboard Φόρτωση κειμένου στο πρόχειρο
Local Libraries Τοπικές βιβλιοθήκες
Locate Object Εντοπισμός αντικειμένου
Locate Selected Objects Εντοπισμός επιλεγμένων αντικειμένων
Lock (⌘2) Κλείδωμα (⌘2)
Lock Grid Κλείδωμα πλέγματος
Lock Others Κλείδωμα υπόλοιπων
@@ -5093,7 +5103,9 @@ effect Points Σημεία
@msg choose a destination folder επιλέξτε φάκελο προορισμού
@msg choose a source folder επιλέξτε φάκελο προέλευσης
@msg open a document with data sets ανοίξτε ένα έγγραφο με σύνολα δεδομένων
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for δεν εφαρμόστηκε τίποτα από το σύνολο δεδομένων {name}: κανένα αντικείμενο δεν είναι συνδεδεμένο με μεταβλητή για την οποία έχει τιμή
@msg select an Intertwine group επιλέξτε μια ομάδα διαπλοκής
@msg select an Objects on Path group επιλέξτε μια ομάδα αντικειμένων σε διαδρομή
@msg span must be "object" or "selection", got {v} το span πρέπει να είναι "object" ή "selection", δόθηκε {v}
@msg span: "selection" places a `gradient`, not a swatch span: το "selection" τοποθετεί ένα `gradient`, όχι δείγμα χρώματος
@msg span: "selection" places a gradient: give `gradient` span: το "selection" τοποθετεί ντεγκραντέ: δώστε `gradient`
@@ -5166,3 +5178,4 @@ effect Points Σημεία
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them οι μεταβλητές και τα σύνολα δεδομένων του αρχείου δεν διαβάστηκαν (μεταβλητές: {variables}, σύνολα δεδομένων: {data_sets}): ο πίνακας Μεταβλητές δεν τα εμφανίζει
# Select by Color (select.color)
Select by Color Επιλογή βάσει χρώματος
Move Anchor Point Μετακίνηση σημείου αγκύρωσης
Can't render this file because it contains an unexpected character in line 2015 and column 19.
+13
View File
@@ -1199,6 +1199,8 @@
Large Gaps Huecos grandes
Gap Preview Color: Color de previsualización de huecos:
Close Gaps With Paths Cerrar huecos con trazados
Select Fills Seleccionar rellenos
Select Strokes Seleccionar trazos
Gaussian Blur… Desenfoque gaussiano…
General General
General › Placement › X General › Colocación › X
@@ -1396,6 +1398,12 @@
Release Blend Soltar fusión
Release Envelope Soltar envolvente
Release Intertwine Soltar entrelazado
Objects on Path Objetos en trazado
Attach Adjuntar
Objects on Path Options Opciones de objetos en trazado
Objects on Path Tool Herramienta Objetos en trazado
Attach Objects on Path Adjuntar objetos al trazado
Expand Objects on Path Expandir objetos en trazado
Release Live Paint Soltar pintura interactiva
Release Repeat Soltar repetición
Release Slice Soltar sector
@@ -1713,6 +1721,7 @@
Load Text into Clipboard Cargar texto en el portapapeles
Local Libraries Bibliotecas locales
Locate Object Localizar objeto
Locate Selected Objects Localizar objetos seleccionados
Location Ubicación
Location: Ubicación:
Lock Bloquear
@@ -3011,6 +3020,7 @@
Snap to Pixel Ajustar a píxel
Precise Cursors Cursores precisos
Snap to Point Ajustar a punto
Snap to Unit Ajustar a unidad
Snapping Tolerance Tolerancia de ajuste
Social Square Post 1080×1080 Publicación cuadrada para redes 1080×1080
Soft Light Luz suave
@@ -4097,6 +4107,7 @@ axis Both Ambos
@msg select a linked image: an embedded one has no file selecciona una imagen vinculada: una incrustada no tiene archivo
@msg select a repeat selecciona una repetición
@msg select an Intertwine group selecciona un grupo entrelazado
@msg select an Objects on Path group selecciona un grupo de objetos en trazado
@msg select a stroke with a variable width selecciona un trazo con anchura variable
@msg select a symbol instance selecciona una instancia de símbolo
@msg select an Image Trace object selecciona un objeto de calco de imagen
@@ -5096,6 +5107,7 @@ mark-style Japanese Japonesas
@msg choose a destination folder elija una carpeta de destino
@msg choose a source folder elija una carpeta de origen
@msg open a document with data sets abra un documento con conjuntos de datos
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for no se aplicó nada del conjunto de datos {name}: ningún objeto está vinculado a una variable para la que tenga un valor
@msg the action has no steps la acción no tiene pasos
# User Interface › Accent Color
@@ -5156,3 +5168,4 @@ mark-style Japanese Japonesas
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no se leyeron las variables ni los conjuntos de datos del archivo (variables: {variables}, conjuntos de datos: {data_sets}): el panel Variables no los muestra
# Select by Color (select.color)
Select by Color Seleccionar por color
Move Anchor Point Mover punto de ancla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+80
View File
@@ -271,6 +271,8 @@
Large Gaps Suuret aukot
Gap Preview Color: Aukkojen esikatseluväri:
Close Gaps With Paths Sulje aukot poluilla
Select Fills Valitse täytöt
Select Strokes Valitse viivat
Image Trace Kuvan jäljitys
Image Trace (Make and Expand) Kuvan jäljitys (luo ja laajenna)
Release Image Trace Vapauta kuvan jäljitys
@@ -1539,6 +1541,12 @@ lighting Light Valo
Release Blend Vapauta sulautus
Release Envelope Vapauta kuori
Release Intertwine Vapauta lomitus
Objects on Path Objektit polulla
Attach Kiinnitä
Objects on Path Options Objektit polulla -asetukset
Objects on Path Tool Objektit polulla -työkalu
Attach Objects on Path Kiinnitä objektit polulle
Expand Objects on Path Laajenna objektit polulla
Release Live Paint Vapauta elävä maalaus
Release Repeat Vapauta toisto
Release Slice Vapauta siivu
@@ -1868,6 +1876,7 @@ lighting Light Valo
Right-Justified Tab Oikealle tasattu sarkain
Select a text object to set its tab stops. Valitse tekstiobjekti, jonka sarkainkohdat haluat asettaa.
Select text Valitse teksti
Snap to Unit Kohdista yksikköön
+ Add Scale + Lisää skaalaus
Add Selected Artwork as One Asset Lisää valittu kuvitus yhtenä resurssina
Export Settings Vientiasetukset
@@ -2215,6 +2224,7 @@ lighting Light Valo
Layers Panel Options Tasot-paneelin asetukset
Row Size: Rivin koko:
Show Layers Only Näytä vain tasot
Locate Selected Objects Paikanna valitut objektit
Thumbnails: Pikkukuvat:
Anchor Points Ankkuripisteet
Brush Affects: Sivellin vaikuttaa:
@@ -5105,7 +5115,9 @@ mark-style Japanese Japanilainen
@msg choose a destination folder valitse kohdekansio
@msg choose a source folder valitse lähdekansio
@msg open a document with data sets avaa asiakirja, jossa on tietojoukkoja
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for tietojoukosta {name} ei käytetty mitään: mitään objektia ei ole sidottu muuttujaan, jolle siinä on arvo
@msg select an Intertwine group valitse lomitusryhmä
@msg select an Objects on Path group valitse Objektit polulla -ryhmä
@msg span must be "object" or "selection", got {v} span-arvon on oltava "object" tai "selection", saatiin {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" sijoittaa `gradient`-liukuvärin, ei värimallia
@msg span: "selection" places a gradient: give `gradient` span: "selection" sijoittaa liukuvärin: anna `gradient`
@@ -5178,3 +5190,71 @@ mark-style Japanese Japanilainen
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them tiedoston muuttujia ja tietojoukkoja ei luettu (muuttujia: {variables}, tietojoukkoja: {data_sets}): Muuttujat-paneeli ei näytä niitä
# Select by Color (select.color)
Select by Color Valitse värin mukaan
Angular Kulma
Away from the Line Irti viivasta
Between Line and Object Viivan ja objektin väliin
Drag out from a segment, or click two points, then click to place. Vedä segmentistä tai napsauta kahta pistettä ja sijoita napsauttamalla.
Drag out from a corner, or click two straight lines, then click to place. Vedä kulmasta tai napsauta kahta suoraa viivaa ja sijoita napsauttamalla.
Drag out from a circle or arc for its radius, inwards for its diameter. Vedä ympyrästä tai kaaresta ulospäin säteelle, sisäänpäin halkaisijalle.
Display Näyttö
@msg Unknown dimension style Tuntematon mittatyyli
@msg Unknown unit Tuntematon yksikkö
Mode Tila
Blur Mode Sumennustila
Edge Only Vain reunat
Overlay Edge Reunat päällä
Path Length Polun pituus
swatch [None] [Ei mitään]
swatch [Registration] [Kohdistus]
swatch White Valkoinen
swatch Black Musta
swatch Red Punainen
swatch Orange Red Oranssinpunainen
swatch Orange Oranssi
swatch Amber Meripihka
swatch Yellow Keltainen
swatch Yellow Green Kellanvihreä
swatch Lime Limetti
swatch Green Vihreä
swatch Emerald Smaragdi
swatch Teal Sinivihreä
swatch Aqua Vedensininen
swatch Cyan Green Syaaninvihreä
swatch Cyan Syaani
swatch Azure Taivaansininen
swatch Blue Sininen
swatch Indigo Indigo
swatch Violet Violetti
swatch Purple Purppura
swatch Magenta Magenta
swatch Rose Ruusunpunainen
swatch Pink Vaaleanpunainen
swatch Brown Ruskea
swatch Tan Kellanruskea
swatch Coffee Kahvi
swatch Chocolate Suklaa
swatch Sand Hiekka
swatch Copper Kupari
swatch Rust Ruoste
swatch Walnut Pähkinä
swatch Espresso Espresso
swatch White, Black Valkoinen, musta
swatch Radial White, Black Säteittäinen valkoinen, musta
swatch Sunset Auringonlasku
swatch Ocean Valtameri
swatch Grays Harmaat
swatch Brights Kirkkaat
swatch Bright Red Kirkas punainen
swatch Bright Yellow Kirkas keltainen
swatch Bright Green Kirkas vihreä
swatch Bright Blue Kirkas sininen
swatch Bright Violet Kirkas violetti
Automate Automatisoi
Include All Subdirectories Sisällytä kaikki alikansiot
Override Action "Open" Commands Ohita toiminnon "Avaa"-komennot
Override Action "Save" Commands Ohita toiminnon "Tallenna"-komennot
Override Action "Export" Commands Ohita toiminnon "Vie"-komennot
Stop on Errors Pysäytä virheisiin
Save and Close Tallenna ja sulje
Play: Toista:
Move Anchor Point Siirrä ankkuripistettä
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1198,6 +1198,8 @@
Large Gaps Grands espaces
Gap Preview Color: Couleur d’aperçu des espaces :
Close Gaps With Paths Fermer les espaces avec des tracés
Select Fills Sélectionner les fonds
Select Strokes Sélectionner les contours
Gaussian Blur… Flou gaussien…
General Général
General › Placement › X Général › Placement › X
@@ -1395,6 +1397,12 @@
Release Blend Annuler le dégradé de formes
Release Envelope Annuler l’enveloppe
Release Intertwine Annuler l’entrelacement
Objects on Path Objets sur tracé
Attach Attacher
Objects on Path Options Options d’objets sur tracé
Objects on Path Tool Outil Objets sur tracé
Attach Objects on Path Attacher des objets au tracé
Expand Objects on Path Décomposer les objets sur tracé
Release Live Paint Annuler la peinture dynamique
Release Repeat Annuler la répétition
Release Slice Annuler la tranche
@@ -1712,6 +1720,7 @@
Load Text into Clipboard Charger du texte dans le presse-papiers
Local Libraries Bibliothèques locales
Locate Object Localiser l’objet
Locate Selected Objects Localiser les objets sélectionnés
Location Emplacement
Location: Emplacement :
Lock Verrouiller
@@ -3008,6 +3017,7 @@
Snap to Pixel Magnétisme du pixel
Precise Cursors Pointeurs précis
Snap to Point Magnétisme du point
Snap to Unit Magnétisme de l’unité
Snapping Tolerance Tolérance du magnétisme
Social Square Post 1080×1080 Publication carrée pour réseaux sociaux 1080×1080
Soft Light Lumière tamisée
@@ -4094,6 +4104,7 @@ axis Both Les deux
@msg select a linked image: an embedded one has no file sélectionnez une image liée : une image incorporée n’a pas de fichier
@msg select a repeat sélectionnez une répétition
@msg select an Intertwine group sélectionnez un groupe entrelacé
@msg select an Objects on Path group sélectionnez un groupe d’objets sur tracé
@msg select a stroke with a variable width sélectionnez un contour de largeur variable
@msg select a symbol instance sélectionnez une instance de symbole
@msg select an Image Trace object sélectionnez un objet de vectorisation de l’image
@@ -5153,6 +5164,7 @@ theme Appearance Apparence
@msg choose a destination folder choisissez un dossier de destination
@msg choose a source folder choisissez un dossier source
@msg open a document with data sets ouvrez un document avec des jeux de données
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for rien du jeu de données {name} n’a été appliqué, car aucun objet n’est lié à une variable pour laquelle il a une valeur
@msg the action has no steps l'action n'a aucune étape
# User Interface › Accent Color
@@ -5213,3 +5225,4 @@ theme Appearance Apparence
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them les variables et jeux de données du fichier n’ont pas été lus (variables : {variables}, jeux de données : {data_sets}) : le panneau Variables ne les affiche pas
# Select by Color (select.color)
Select by Color Sélectionner par couleur
Move Anchor Point Déplacer le point d’ancrage
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1504,6 +1504,8 @@ file-filter Templates Sablonok
Large Gaps Nagy hézagok
Gap Preview Color: Hézagok előnézeti színe:
Close Gaps With Paths Hézagok lezárása útvonalakkal
Select Fills Kitöltések kijelölése
Select Strokes Körvonalak kijelölése
Gaussian Blur Gauss-elmosás
Gaussian Blur… Gauss-elmosás…
General Általános
@@ -1769,6 +1771,12 @@ file-filter Templates Sablonok
Release Blend Összemosás megszüntetése
Release Envelope Burkoló megszüntetése
Release Intertwine Összefonás megszüntetése
Objects on Path Objektumok útvonalon
Attach Csatolás
Objects on Path Options Objektumok útvonalon beállításai
Objects on Path Tool Objektumok útvonalon
Attach Objects on Path Objektumok csatolása útvonalhoz
Expand Objects on Path Objektumok útvonalon kibontása
Release Live Paint Élő festés megszüntetése
Release Repeat Ismétlés megszüntetése
Release Slice Szelet megszüntetése
@@ -2162,6 +2170,7 @@ file-filter Templates Sablonok
Loaded Texture Betöltött textúra
Local Libraries Helyi könyvtárak
Locate Object Objektum megkeresése
Locate Selected Objects Kijelölt objektumok megkeresése
Location Hely
Location: Hely:
Lock Zárolás
@@ -3631,6 +3640,7 @@ mark-style Japanese Japán
Snap to Pixel Illesztés pixelhez
Precise Cursors Pontos kurzorok
Snap to Point Illesztés pontra
Snap to Unit Illesztés mértékegységhez
Snapping Tolerance Illesztési tűrés
Social Közösségi média
Social Landscape Post 1200×630 Fekvő közösségimédia-bejegyzés 1200×630
@@ -4786,6 +4796,7 @@ imageTrace Outline Körvonal
@msg select a Live Paint group jelölj ki egy élőfestés-csoportot
@msg select a repeat jelölj ki egy ismétlést
@msg select an Intertwine group jelölj ki egy összefonási csoportot
@msg select an Objects on Path group jelölj ki egy Objektumok útvonalon csoportot
@msg select a stroke with a variable width jelölj ki egy változó szélességű körvonalat
@msg select a symbol instance jelölj ki egy szimbólumpéldányt
@msg select an envelope jelölj ki egy burkolót
@@ -5140,6 +5151,7 @@ imageTrace Outline Körvonal
@msg choose a destination folder válasszon célmappát
@msg choose a source folder válasszon forrásmappát
@msg open a document with data sets nyisson meg egy adatkészleteket tartalmazó dokumentumot
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for ebből az adatkészletből semmi sem lett alkalmazva: {name}; egyetlen objektum sincs olyan változóhoz kötve, amelynek értéke van benne
@msg the action has no steps a műveletnek nincsenek lépései
# User Interface › Accent Color
@@ -5200,3 +5212,4 @@ imageTrace Outline Körvonal
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them a fájl változói és adatkészletei nem lettek beolvasva (változók: {variables}, adatkészletek: {data_sets}): a Változók panel nem listázza őket
# Select by Color (select.color)
Select by Color Kijelölés szín szerint
Move Anchor Point Horgonypont áthelyezése
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1734,6 +1734,7 @@ lighting Light Cahaya
Right-Justified Tab Tab rata kanan
Select a text object to set its tab stops. Pilih objek teks untuk mengatur tab stop-nya.
Select text Pilih teks
Snap to Unit Snap ke satuan
+ Add Scale + Tambah skala
Add Selected Artwork as One Asset Tambahkan karya seni terpilih sebagai satu aset
Export Settings Pengaturan ekspor
@@ -2080,6 +2081,7 @@ lighting Light Cahaya
Layers Panel Options Opsi panel lapisan
Row Size: Ukuran baris:
Show Layers Only Tampilkan lapisan saja
Locate Selected Objects Temukan objek terpilih
Thumbnails: Miniatur:
Anchor Points Titik jangkar
Brush Affects: Kuas memengaruhi:
@@ -5074,6 +5076,8 @@ swatch Bright Violet Violet cerah
Chop Design Potong desain
Clear Events Hapus peristiwa
Close Gaps With Paths Tutup celah dengan jalur
Select Fills Pilih isian
Select Strokes Pilih stroke
Column Type: Jenis kolom:
Each Design Represents: Setiap desain mewakili:
Event: Peristiwa:
@@ -5151,6 +5155,12 @@ swatch Bright Violet Violet cerah
Release Blend Lepaskan blend
Release Envelope Lepaskan amplop
Release Intertwine Lepaskan jalinan
Objects on Path Objek pada jalur
Attach Tempelkan
Objects on Path Options Opsi objek pada jalur
Objects on Path Tool Alat objek pada jalur
Attach Objects on Path Tempelkan objek pada jalur
Expand Objects on Path Perluas objek pada jalur
Release Live Paint Lepaskan Live Paint
Release Repeat Lepaskan perulangan
Release Slice Lepaskan slice
@@ -5197,3 +5207,4 @@ swatch Bright Violet Violet cerah
Justification Justifikasi
# Select by Color (select.color)
Select by Color Pilih berdasarkan warna
Move Anchor Point Pindahkan titik jangkar
Can't render this file because it contains an unexpected character in line 3770 and column 19.
+13
View File
@@ -1208,6 +1208,8 @@ file-filter Templates Modelli
Large Gaps Spazi vuoti grandi
Gap Preview Color: Colore anteprima spazi vuoti:
Close Gaps With Paths Chiudi spazi vuoti con tracciati
Select Fills Seleziona riempimenti
Select Strokes Seleziona tracce
Gaussian Blur… Sfocatura gaussiana…
General Generale
General › Placement › X Generale › Posizionamento › X
@@ -1415,6 +1417,12 @@ file-filter Templates Modelli
Release Envelope Rilascia involucro
Release Image Trace Rilascia ricalco immagine
Release Intertwine Rilascia intreccio
Objects on Path Oggetti su tracciato
Attach Aggancia
Objects on Path Options Opzioni oggetti su tracciato
Objects on Path Tool Strumento Oggetti su tracciato
Attach Objects on Path Aggancia oggetti al tracciato
Expand Objects on Path Espandi oggetti su tracciato
Release Live Paint Rilascia pittura dinamica
Release Repeat Rilascia ripetizione
Release Slice Rilascia sezione
@@ -1739,6 +1747,7 @@ file-filter Templates Modelli
Load Text into Clipboard Carica testo negli appunti
Local Libraries Librerie locali
Locate Object Individua oggetto
Locate Selected Objects Individua oggetti selezionati
Location Posizione
Location: Posizione:
Lock Blocca
@@ -3044,6 +3053,7 @@ mark-style Japanese Giapponese
Snap to Pixel Aggancia ai pixel
Precise Cursors Cursori di precisione
Snap to Point Aggancia al punto
Snap to Unit Aggancia all'unità
Snapping Tolerance Tolleranza di aggancio
Social Square Post 1080×1080 Post quadrato per social 1080×1080
Soft Light Luce soffusa
@@ -4136,6 +4146,7 @@ axis Both Entrambi
@msg select a linked image: an embedded one has no file seleziona un'immagine collegata: un'immagine incorporata non ha un file
@msg select a repeat seleziona una ripetizione
@msg select an Intertwine group seleziona un gruppo intrecciato
@msg select an Objects on Path group seleziona un gruppo di oggetti su tracciato
@msg select a stroke with a variable width seleziona una traccia con larghezza variabile
@msg select a symbol instance seleziona un'istanza di simbolo
@msg select an Image Trace object seleziona un oggetto di ricalco immagine
@@ -4766,6 +4777,7 @@ lighting Light Luce
@msg choose a destination folder scegli una cartella di destinazione
@msg choose a source folder scegli una cartella di origine
@msg open a document with data sets apri un documento con set di dati
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for non è stato applicato nulla del set di dati {name}: nessun oggetto è associato a una variabile per cui ha un valore
@msg the action has no steps l'azione non ha passaggi
# User Interface › Accent Color
@@ -4828,3 +4840,4 @@ lighting Light Luce
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them le variabili e i set di dati del file non sono stati letti (variabili: {variables}, set di dati: {data_sets}): il pannello Variabili non li elenca
# Select by Color (select.color)
Select by Color Seleziona per colore
Move Anchor Point Sposta punto di ancoraggio
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -262,6 +262,8 @@
Large Gaps 大きい間隔
Gap Preview Color: 間隔のプレビューカラー:
Close Gaps With Paths パスで間隔を閉じる
Select Fills 塗りを選択
Select Strokes 線を選択
Image Trace 画像トレース
Image Tracing 画像トレースオブジェクト
Tracing Presets トレースプリセット
@@ -1660,6 +1662,12 @@
Release Envelope エンベロープを解除
Release Image Trace 画像トレースを解除
Release Intertwine 織り交ぜを解除
Objects on Path パス上のオブジェクト
Attach アタッチ
Objects on Path Options パス上のオブジェクトオプション
Objects on Path Tool パス上のオブジェクトツール
Attach Objects on Path パス上のオブジェクトをアタッチ
Expand Objects on Path パス上のオブジェクトを拡張
Release Live Paint ライブペイントを解除
Release Repeat リピートを解除
Release Slice スライスを解除
@@ -1911,6 +1919,7 @@
Load Text into Clipboard テキストをクリップボードに読み込む
Local Libraries ローカルライブラリ
Locate Object オブジェクトを検索
Locate Selected Objects 選択したオブジェクトを検索
Location 場所
Location: 位置:
Lock (⌘2) ロック (⌘2)
@@ -2821,6 +2830,7 @@ mark-style Japanese 日本式
Source Image 元画像
Image Trace View 画像トレースの表示
Snap Options スナップオプション
Snap to Unit 単位にスナップ
Snapping Tolerance スナップ許容値
Soft Light ソフトライト
Solid 実線
@@ -4019,6 +4029,7 @@ swatch Bright Violet ビビッドバイオレット
@msg pattern missing パターンが見つかりません
@msg select a repeat リピートを選択してください
@msg select an Intertwine group 織り交ぜグループを選択してください
@msg select an Objects on Path group パス上のオブジェクトグループを選択してください
@msg select the art to repeat リピートするアートを選択してください
@msg the document has no patterns ドキュメントにパターンがありません
@msg no artboard アートボードがありません
@@ -4806,6 +4817,7 @@ lighting Light ライト
@msg choose a destination folder 出力先フォルダーを選択してください
@msg choose a source folder ソースフォルダーを選択してください
@msg open a document with data sets データセットのあるドキュメントを開いてください
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for データセット {name} から何も適用されませんでした: 値のある変数にバインドされたオブジェクトがありません
@msg the action has no steps アクションにステップがありません
# User Interface › Accent Color
@@ -4868,3 +4880,4 @@ lighting Light ライト
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ファイルの変数とデータセットは読み込まれませんでした (変数: {variables}、データセット: {data_sets})。変数パネルには表示されません
# Select by Color (select.color)
Select by Color カラーで選択
Move Anchor Point アンカーポイントを移動
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1635,6 +1635,7 @@ lighting Light სინათლე
Right-Justified Tab მარჯვნივ სწორებული ტაბულაცია
Select a text object to set its tab stops. ტაბულაციის გაჩერებების დასაყენებლად მონიშნეთ ტექსტური ობიექტი.
Select text მონიშნეთ ტექსტი
Snap to Unit ერთეულზე მიბმა
+ Add Scale + მასშტაბის დამატება
Add Selected Artwork as One Asset მონიშნული ნახატის დამატება ერთ რესურსად
Export Settings ექსპორტის პარამეტრები
@@ -1978,6 +1979,7 @@ lighting Light სინათლე
Layers Panel Options შრეების პანელის პარამეტრები
Row Size: სტრიქონის ზომა:
Show Layers Only მხოლოდ შრეების ჩვენება
Locate Selected Objects მონიშნული ობიექტების მოძებნა
Thumbnails: ესკიზები:
Anchor Points საყრდენი წერტილები
Brush Affects: ფუნჯი მოქმედებს:
@@ -4828,6 +4830,7 @@ imageTrace Outline კონტური
Intertwine გადახლართვა
Arrange Intertwine Area გადახლართვის არის დალაგება
@msg select an Intertwine group მონიშნეთ გადახლართვის ჯგუფი
@msg select an Objects on Path group მონიშნეთ ტრაექტორიაზე ობიექტების ჯგუფი
Registration: საბაზისო წერტილი:
@msg span must be "object" or "selection", got {v} span უნდა იყოს "object" ან "selection", მიღებულია {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" ათავსებს `gradient`-ს და არა ფერის ნიმუშს
@@ -4848,6 +4851,7 @@ imageTrace Outline კონტური
@msg choose a destination folder აირჩიეთ დანიშნულების საქაღალდე
@msg choose a source folder აირჩიეთ წყარო საქაღალდე
@msg open a document with data sets გახსენით დოკუმენტი მონაცემთა ნაკრებებით
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for მონაცემთა ნაკრები {name}: არაფერი გამოყენებულა; არცერთი ობიექტი არ არის მიბმული ცვლადზე, რომელსაც მასში მნიშვნელობა აქვს
@msg the action has no steps მოქმედებას ნაბიჯები არ აქვს
Convert to Straight Lines სწორ ხაზებად გარდაქმნა
Corner Point Angle Threshold: კუთხოვანი წერტილის კუთხის ზღვარი:
@@ -5012,6 +5016,12 @@ imageTrace Outline კონტური
Release Blend გადასვლის მოხსნა
Release Envelope გარსის მოხსნა
Release Intertwine გადახლართვის მოხსნა
Objects on Path ობიექტები ტრაექტორიაზე
Attach მიმაგრება
Objects on Path Options ტრაექტორიაზე ობიექტების პარამეტრები
Objects on Path Tool ტრაექტორიაზე ობიექტების ხელსაწყო
Attach Objects on Path ობიექტების ტრაექტორიაზე მიმაგრება
Expand Objects on Path ტრაექტორიაზე ობიექტების გაშლა
Release Live Paint ცოცხალი შეღებვის მოხსნა
Release Repeat გამეორების მოხსნა
Release Slice ნაჭრის მოხსნა
@@ -5032,6 +5042,8 @@ imageTrace Outline კონტური
Resize Type Area ტექსტის არის ზომის შეცვლა
Scale Design დიზაინის მასშტაბირება
Scale in Perspective პერსპექტივაში მასშტაბირება
Select Fills შევსებების მონიშვნა
Select Strokes კონტურების მონიშვნა
Set History Limit… ისტორიის ლიმიტის დაყენება…
Shape Builder Delete ფიგურების კონსტრუქტორით წაშლა
Sliding სრიალა
@@ -5121,3 +5133,4 @@ imageTrace Outline კონტური
# Select by Color (select.color)
Select by Color მონიშვნა ფერის მიხედვით
@msg thumbnail: no page {n} (the file has 1) thumbnail: გვერდი {n} არ არსებობს (ფაილს 1 გვერდი აქვს)
Move Anchor Point საყრდენი წერტილის გადაადგილება
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -258,6 +258,8 @@
Large Gaps 큰 간격
Gap Preview Color: 간격 미리 보기 색상:
Close Gaps With Paths 패스로 간격 닫기
Select Fills 칠 선택
Select Strokes 선 선택
Image Trace 이미지 추적
Image Tracing 이미지 추적
Tracing Presets 추적 프리셋
@@ -1655,6 +1657,12 @@
Release Envelope 엔벨로프 해제
Release Image Trace 이미지 추적 해제
Release Intertwine 교차 해제
Objects on Path 패스 위 개체
Attach 부착
Objects on Path Options 패스 위 개체 옵션
Objects on Path Tool 패스 위 개체 도구
Attach Objects on Path 패스 위 개체 부착
Expand Objects on Path 패스 위 개체 확장
Release Live Paint 라이브 페인트 해제
Release Repeat 반복 해제
Release Slice 분할 해제
@@ -1906,6 +1914,7 @@
Load Text into Clipboard 텍스트를 클립보드로 불러오기
Local Libraries 로컬 라이브러리
Locate Object 개체 찾기
Locate Selected Objects 선택한 개체 찾기
Location 위치
Location: 위치:
Lock (⌘2) 잠금 (⌘2)
@@ -2815,6 +2824,7 @@ mark-style Japanese 일본식
Source Image 원본 이미지
Image Trace View 이미지 추적 보기
Snap Options 스냅 옵션
Snap to Unit 단위에 스냅
Snapping Tolerance 스냅 허용 오차
Soft Light 소프트 라이트
Solid 단색
@@ -3951,6 +3961,7 @@ theme Appearance 외관
@msg pattern missing 패턴이 없습니다
@msg select a repeat 반복을 선택하세요
@msg select an Intertwine group 교차 그룹을 선택하세요
@msg select an Objects on Path group 패스 위 개체 그룹을 선택하세요
@msg select the art to repeat 반복할 아트웍을 선택하세요
@msg the document has no patterns 문서에 패턴이 없습니다
@msg no artboard 아트보드가 없습니다
@@ -4729,6 +4740,7 @@ lighting Light 광원
@msg choose a destination folder 대상 폴더를 선택하세요
@msg choose a source folder 소스 폴더를 선택하세요
@msg open a document with data sets 데이터 세트가 있는 문서를 여세요
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for 데이터 세트 {name}에서 적용된 항목이 없습니다: 값이 있는 변수에 연결된 개체가 없습니다
@msg the action has no steps 액션에 단계가 없습니다
# User Interface › Accent Color
@@ -4791,3 +4803,4 @@ lighting Light 광원
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 파일의 변수와 데이터 세트를 읽지 않았습니다(변수: {variables}, 데이터 세트: {data_sets}). 변수 패널에 표시되지 않습니다
# Select by Color (select.color)
Select by Color 색상별 선택
Move Anchor Point 앵커 포인트 이동
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1748,6 +1748,7 @@ lighting Light Licht
Right-Justified Tab Rechts uitgelijnde tabstop
Select a text object to set its tab stops. Selecteer een tekstobject om de tabstops in te stellen.
Select text Selecteer tekst
Snap to Unit Uitlijnen op eenheid
+ Add Scale + Schaal toevoegen
Add Selected Artwork as One Asset Geselecteerde artwork als één asset toevoegen
Export Settings Exportinstellingen
@@ -2094,6 +2095,7 @@ lighting Light Licht
Layers Panel Options Opties voor het paneel Lagen
Row Size: Rijgrootte:
Show Layers Only Alleen lagen weergeven
Locate Selected Objects Geselecteerde objecten lokaliseren
Thumbnails: Miniaturen:
Anchor Points Ankerpunten
Brush Affects: Penseel beïnvloedt:
@@ -5086,6 +5088,8 @@ swatch Bright Violet Felviolet
Chop Design Ontwerp afknippen
Clear Events Gebeurtenissen wissen
Close Gaps With Paths Openingen sluiten met paden
Select Fills Vullingen selecteren
Select Strokes Lijnen selecteren
Column Type: Kolomtype:
Each Design Represents: Elk ontwerp staat voor:
Event: Gebeurtenis:
@@ -5163,6 +5167,12 @@ swatch Bright Violet Felviolet
Release Blend Vermenging vrijgeven
Release Envelope Omvorming vrijgeven
Release Intertwine Verstrengeling vrijgeven
Objects on Path Objecten op pad
Attach Koppelen
Objects on Path Options Opties voor objecten op pad
Objects on Path Tool Objecten-op-pad-gereedschap
Attach Objects on Path Objecten aan pad koppelen
Expand Objects on Path Objecten op pad uitbreiden
Release Live Paint Live Paint vrijgeven
Release Repeat Herhaling vrijgeven
Release Slice Slice vrijgeven
@@ -5239,3 +5249,4 @@ swatch Bright Violet Felviolet
Justification Uitvullen
# Select by Color (select.color)
Select by Color Selecteren op kleur
Move Anchor Point Ankerpunt verplaatsen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -229,6 +229,8 @@
Large Gaps Duże przerwy
Gap Preview Color: Kolor podglądu przerw:
Close Gaps With Paths Zamknij przerwy ścieżkami
Select Fills Zaznaczaj wypełnienia
Select Strokes Zaznaczaj obrysy
Image Trace Obrys obrazu
Text Wrap Oblewanie tekstem
Text Wrap Options… Opcje oblewania tekstem…
@@ -1484,6 +1486,12 @@ lighting Light Światło
Release Envelope Uwolnij kopertę
Release Image Trace Uwolnij obrys obrazu
Release Intertwine Uwolnij przeplatanie
Objects on Path Obiekty na ścieżce
Attach Dołącz
Objects on Path Options Opcje obiektów na ścieżce
Objects on Path Tool Narzędzie Obiekty na ścieżce
Attach Objects on Path Dołącz obiekty do ścieżki
Expand Objects on Path Rozszerz obiekty na ścieżce
Release Live Paint Uwolnij aktywne malowanie
Release Repeat Uwolnij powtórzenie
Release Slice Uwolnij plasterek
@@ -1824,6 +1832,7 @@ lighting Light Światło
Right-Justified Tab Tabulator prawy
Select a text object to set its tab stops. Zaznacz obiekt tekstowy, aby ustawić jego tabulatory.
Select text Zaznacz tekst
Snap to Unit Przyciągaj do jednostek
+ Add Scale + Dodaj skalę
Add Selected Artwork as One Asset Dodaj zaznaczoną kompozycję jako jeden zasób
Export Settings Ustawienia eksportu
@@ -2174,6 +2183,7 @@ lighting Light Światło
Layers Panel Options Opcje panelu Warstwy
Row Size: Rozmiar wiersza:
Show Layers Only Pokaż tylko warstwy
Locate Selected Objects Znajdź zaznaczone obiekty
Thumbnails: Miniatury:
Anchor Points Punkty kontrolne
Brush Affects: Pędzel wpływa na:
@@ -4222,6 +4232,7 @@ imageTrace Outline Kontur
@msg select a linked image: an embedded one has no file zaznacz obraz połączony: osadzony nie ma pliku
@msg select a repeat zaznacz powtórzenie
@msg select an Intertwine group zaznacz grupę przeplotu
@msg select an Objects on Path group zaznacz grupę obiektów na ścieżce
@msg select a stroke with a variable width zaznacz obrys o zmiennej szerokości
@msg select a symbol instance zaznacz wystąpienie symbolu
@msg select an Image Trace object zaznacz obiekt obrysu obrazu
@@ -5005,6 +5016,7 @@ mark-style Japanese Japońskie
@msg choose a destination folder wybierz folder docelowy
@msg choose a source folder wybierz folder źródłowy
@msg open a document with data sets otwórz dokument z zestawami danych
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for z zestawu danych {name} nic nie zastosowano: żaden obiekt nie jest powiązany ze zmienną, dla której ma on wartość
@msg the action has no steps operacja nie ma kroków
# User Interface › Accent Color
@@ -5066,3 +5078,4 @@ mark-style Japanese Japońskie
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them zmienne i zestawy danych pliku nie zostały wczytane (zmienne: {variables}, zestawy danych: {data_sets}): panel Zmienne ich nie wyświetla
# Select by Color (select.color)
Select by Color Zaznacz według koloru
Move Anchor Point Przesuń punkt kontrolny
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1056,6 +1056,8 @@
Large Gaps Lacunas Grandes
Gap Preview Color: Cor de Visualização das Lacunas:
Close Gaps With Paths Fechar Lacunas com Caminhos
Select Fills Selecionar Preenchimentos
Select Strokes Selecionar Traçados
Gaussian Blur… Desfoque Gaussiano…
General Geral
Generate CSS Gerar CSS
@@ -1210,6 +1212,12 @@
Release Blend Liberar Mesclagem
Release Envelope Liberar Envelope
Release Intertwine Liberar Entrelaçamento
Objects on Path Objetos no Caminho
Attach Anexar
Objects on Path Options Opções de Objetos no Caminho
Objects on Path Tool Ferramenta Objetos no Caminho
Attach Objects on Path Anexar Objetos ao Caminho
Expand Objects on Path Expandir Objetos no Caminho
Release Live Paint Liberar Pintura ao Vivo
Release Repeat Liberar Repetição
Release Slice Liberar Fatia
@@ -2501,6 +2509,7 @@
Snap to Pixel Ajustar ao Pixel
Precise Cursors Cursores Precisos
Snap to Point Ajustar ao Ponto
Snap to Unit Ajustar à Unidade
Snapping Tolerance Tolerância de Ajuste
Soft Light Luz Suave
Solid Sólido
@@ -3114,6 +3123,7 @@ lighting Light Luz
Layers Panel Options Opções do painel Camadas
Left-to-Right Paragraph Direction Direção do parágrafo da esquerda para a direita
Line-end Punctuation Half Width Pontuação de fim de linha em meia largura
Locate Selected Objects Localizar objetos selecionados
Lock Others Bloquear outros
Merge Selected Mesclar selecionados
Model Modelo
@@ -3678,6 +3688,7 @@ axis Both Ambos
@msg select a linked image: an embedded one has no file selecione uma imagem vinculada: uma incorporada não tem arquivo
@msg select a repeat selecione uma repetição
@msg select an Intertwine group selecione um grupo entrelaçado
@msg select an Objects on Path group selecione um grupo de objetos no caminho
@msg select a stroke with a variable width selecione um traço com largura variável
@msg select a symbol instance selecione uma instância de símbolo
@msg select an Image Trace object selecione um objeto de Vetorização de imagem
@@ -5032,6 +5043,7 @@ mark-style Japanese Japonesas
@msg choose a destination folder escolha uma pasta de destino
@msg choose a source folder escolha uma pasta de origem
@msg open a document with data sets abra um documento com conjuntos de dados
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for nada do conjunto de dados {name} foi aplicado: nenhum objeto está vinculado a uma variável para a qual ele tem um valor
@msg the action has no steps a ação não tem etapas
@msg Angles require two straight segments Ângulos exigem dois segmentos retos
@msg Choose a circle or a perfect arc Escolha um círculo ou um arco perfeito
@@ -5131,3 +5143,4 @@ mark-style Japanese Japonesas
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them as variáveis e os conjuntos de dados do arquivo não foram lidos (variáveis: {variables}, conjuntos de dados: {data_sets}): o painel Variáveis não os lista
# Select by Color (select.color)
Select by Color Selecionar por Cor
Move Anchor Point Mover Ponto de Ancoragem
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -1206,6 +1206,8 @@ file-filter Templates Шаблоны
Large Gaps Большие зазоры
Gap Preview Color: Цвет просмотра зазоров:
Close Gaps With Paths Закрыть зазоры контурами
Select Fills Выделять заливки
Select Strokes Выделять обводки
Gaussian Blur… Размытие по Гауссу…
General Общие
General › Placement › X Общие › Размещение › X
@@ -1413,6 +1415,12 @@ file-filter Templates Шаблоны
Release Envelope Освободить оболочку
Release Image Trace Освободить трассировку изображения
Release Intertwine Освободить переплетение
Objects on Path Объекты на контуре
Attach Прикрепить
Objects on Path Options Параметры объектов на контуре
Objects on Path Tool Инструмент «Объекты на контуре»
Attach Objects on Path Прикрепить объекты к контуру
Expand Objects on Path Разобрать объекты на контуре
Release Live Paint Освободить быструю заливку
Release Repeat Освободить повтор
Release Slice Освободить фрагмент
@@ -1737,6 +1745,7 @@ file-filter Templates Шаблоны
Load Text into Clipboard Загрузить текст в буфер обмена
Local Libraries Локальные библиотеки
Locate Object Найти объект
Locate Selected Objects Находить выделенные объекты
Location Расположение
Location: Расположение:
Lock Заблокировать
@@ -3040,6 +3049,7 @@ mark-style Japanese Японский
Snap to Pixel Привязать к пикселю
Precise Cursors Точные курсоры
Snap to Point Привязать к точке
Snap to Unit Привязать к единицам
Snapping Tolerance Допуск привязки
Social Square Post 1080×1080 Квадратный пост для соцсетей 1080×1080
Soft Light Мягкий свет
@@ -4132,6 +4142,7 @@ axis Both Оба
@msg select a linked image: an embedded one has no file выделите связанное изображение: у внедрённого нет файла
@msg select a repeat выделите повтор
@msg select an Intertwine group выделите группу переплетения
@msg select an Objects on Path group выделите группу объектов на контуре
@msg select a stroke with a variable width выделите обводку с переменной шириной
@msg select a symbol instance выделите экземпляр символа
@msg select an Image Trace object выделите объект трассировки изображения
@@ -4762,6 +4773,7 @@ lighting Light Свет
@msg choose a destination folder выберите папку назначения
@msg choose a source folder выберите исходную папку
@msg open a document with data sets откройте документ с наборами данных
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for из набора данных {name} ничего не применено: ни один объект не привязан к переменной, для которой в нём есть значение
@msg the action has no steps в действии нет шагов
# User Interface › Accent Color
@@ -4824,3 +4836,4 @@ lighting Light Свет
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them переменные и наборы данных файла не прочитаны (переменных: {variables}, наборов данных: {data_sets}): панель «Переменные» их не показывает
# Select by Color (select.color)
Select by Color Выделить по цвету
Move Anchor Point Переместить опорную точку
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1176,6 +1176,8 @@
Large Gaps Stora mellanrum
Gap Preview Color: Förhandsgranskningsfärg för mellanrum:
Close Gaps With Paths Stäng mellanrum med banor
Select Fills Markera fyllningar
Select Strokes Markera konturer
Gaussian Blur… Gaussisk oskärpa…
General Allmänt
General › Placement › X Allmänt › Placering › X
@@ -1372,6 +1374,12 @@
Release Blend Släpp blandning
Release Envelope Släpp kuvert
Release Intertwine Släpp sammanflätning
Objects on Path Objekt på bana
Attach Fäst
Objects on Path Options Alternativ för objekt på bana
Objects on Path Tool Verktyg för objekt på bana
Attach Objects on Path Fäst objekt på bana
Expand Objects on Path Expandera objekt på bana
Release Live Paint Släpp direktmålning
Release Repeat Släpp upprepning
Release Slice Släpp skiva
@@ -1672,6 +1680,7 @@
Load Text into Clipboard Läs in text till urklipp
Local Libraries Lokala bibliotek
Locate Object Hitta objekt
Locate Selected Objects Hitta markerade objekt
Location Plats
Location: Plats:
Lock Lås
@@ -2952,6 +2961,7 @@
Snap to Pixel Fäst mot pixel
Precise Cursors Exakta markörer
Snap to Point Fäst mot punkt
Snap to Unit Fäst mot enhet
Snapping Tolerance Fästtolerans
Social Square Post 1080×1080 Kvadratiskt inlägg för sociala medier 1080×1080
Soft Light Mjukt ljus
@@ -5097,7 +5107,9 @@ theme Appearance Utseende
@msg choose a destination folder välj en målmapp
@msg choose a source folder välj en källmapp
@msg open a document with data sets öppna ett dokument med datauppsättningar
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for inget i datauppsättningen {name} användes: inget objekt är bundet till en variabel som den har ett värde för
@msg select an Intertwine group markera en sammanflätningsgrupp
@msg select an Objects on Path group markera en grupp med objekt på bana
@msg span must be "object" or "selection", got {v} span måste vara "object" eller "selection", fick {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" placerar en `gradient`, inte en färgruta
@msg span: "selection" places a gradient: give `gradient` span: "selection" placerar en övertoning: ange `gradient`
@@ -5170,3 +5182,4 @@ theme Appearance Utseende
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them filens variabler och datauppsättningar lästes inte (variabler: {variables}, datauppsättningar: {data_sets}): panelen Variabler visar dem inte
# Select by Color (select.color)
Select by Color Markera efter färg
Move Anchor Point Flytta ankarpunkt
Can't render this file because it contains an unexpected character in line 5 and column 25.
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more