The outlines of a run's character stroke are kept with the glyph geometry the renderer keeps for the type, in a byte-limited cache swept by frame, so type that changes in every frame (an animation) frees them within a few frames.
The Image Trace panel had no way to compare a trace with its source
image except changing View and changing it back.
The panel now has an eye button to the right of View, enabled with an
Image Trace object selected. While it is held down, the canvas draws the
selected Image Trace objects with their source image, whatever their
View, and draws their View again on release. The document, its View and
the undo history are unchanged. The renderer takes the objects in
RenderOptions::trace_source, which is also part of the canvas frame key
and the raster-effect cache key.
To check what it kept for a live object, or for effect art made from
live objects, type or symbols, the renderer walked the object in every
frame to hash the symbols, patterns and placed files it shows, and it
looked up each symbol instance it found by name in the list of every
symbol. At 1000 x 1000 pixels, the walk took most of the frame on a
page of 2,000 envelopes around instances of the last 2,000 of 10,000
symbols: 83 ms a frame when every frame walked them.
The renderer now keeps what the walk found with the node, which it
holds weakly, and with the document's symbols and patterns as the walk
read them. A later frame makes the key from what was kept while the
node and the document's symbols and patterns stay the same, and walks
the node again when any of them changes. A walk that finds a placed
file is not kept. An evaluation that the live cache does not keep, such
as a thumbnail's copy, is no longer walked. The same page now takes
30 ms a frame, with the same pixels.
Docs: docs/target-app-parity.md
Brush art leaves out the symbol instances and pattern paints whose art
draws a brushed stroke. The brush cache now records whether a brush's art
or the stroke's paint can show a symbol or a pattern, and drops those
entries when the art of the document's symbols or patterns changes, so an
edit that makes a symbol or pattern start or stop drawing a brushed stroke
draws as a new renderer does.
The parity line gives the median and fastest-frame figures of the
benchmark for both documents it names.
The renderer kept brush art per node id and stroke. The copies drawn
for symbol instances, inline graphics and repeat copies share their
art's ids, so they replaced each other's brush art and it was made
again in every frame. The pieces were drawn as plain nodes, so brush
art that is a live object, such as an envelope that a scatter brush
places, was evaluated again in every frame, for a path on the page too.
The brush cache now also keys brush art by the stroked path, so each
copy keeps its own. Kept pieces are drawn through the renderer's
caches: their stroke outlines, live-effect geometry and live object
evaluations are kept between frames. Each frame draws what a new
renderer draws. On one thread at 1000 x 1000 pixels, 2,000 paths on
the page whose scatter brush art is an envelope took 400 ms a frame on
main and take 94 ms, and the fastest frames of 2,000 instances of a
symbol whose art is a brushed path went from 16 ms to 12 ms, with the
same pixels.
Docs: docs/target-app-parity.md
CodeRabbit findings #32, #38, #41, #44, #102:
- A frame (and a PDF export) draws symbol instances holding at most 16M objects together
(`vectorcraft_doc::MAX_SYMBOL_NODES`): nested instances multiplied past the depth limits.
- A dotted guide whose control polygon would make more than 100,000 dashes is drawn solid.
- Groups blending with their backdrop, drawn twice each, are drawn isolated past 4 nested.
- An image is decoded only while it and the images paints hold fit twice the decode allowance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* CI: line tables only for workspace crates, no debug info for dependencies
* CI: one integration-test binary per crate
Each crate's tests/*.rs files become modules of tests/it/main.rs. Files that change
process-wide state (Color Settings, the font database, installed plug-ins, the working
directory), the fuzz sweeps and the test targets the Affinity corpus workflow names stay
their own binaries.
* Docs: name the integration tests' new paths
* CI: xtask test with nextest and doctests, one wasm check, clippy --keep-going
cargo xtask ci runs the tests with cargo nextest (cargo test when it is not installed),
builds them with --lib --bins --tests, runs the doctests as their own step, and checks
the wasm crates in one cargo check --keep-going call.
COLR and SVG glyphs, which have no outlines, were left out wherever type was taken as its outlines: geometry effects, Expand Appearance, EMF/WMF export.
The renderer placed symbol art that holds a blend, an envelope or a
repeat again for every instance in every frame. The live object in the
placed copy was a new node each time, and the renderer evaluated it
again. Symbol art that is itself a live object, an opacity mask whose
art is one, and a live object in a clip group in Outline view were
drawn from an uncached copy in every frame. At 1000 x 1000 pixels,
2,000 instances of a symbol holding an envelope took 126 ms a frame on
main, and the same 2,000 envelopes on the page took 30 ms.
The renderer now keeps the placed art of instances and inline graphics
when it holds blends, envelopes or repeats, and when the art is itself
a live object. It draws kept live art, opacity-mask art and Outline
view clip members that are live objects from the live cache, which
evaluates them again when the symbols, patterns, fonts or color
settings they show change. The 2,000 instances take 28 ms a frame,
with the same pixels.
Docs: docs/target-app-parity.md
Review follow-up (CodeRabbit): leaving a dashed brush stroke out of the
paint count still let a fill plus that stroke count as one paint, so the
fill and each brush piece took the object opacity separately and the fill
showed through the brush art. A visible dashed brush stroke now forces the
layered path, like a non-solid paint does, and counts normally otherwise.
brush_fx::tests::object_opacity_fades_a_brushed_stroke_once compares a
brushed rectangle at object opacity 0.5 with the same rectangle in a group
at 0.5, with and without a dash and a fill; before the change the fill
plus dashed brush case had 1440 differing pixels.
Follow-up to #3697 (CodeRabbit): a brush stroke is drawn as separate
pieces (draw_brush), so folding object opacity into its colour isn't exact.
A non-dashed brush stroke already counted as one paint before #3697; to
keep that change's scope, a dashed stroke counts as one paint only when it
has no brush. dashed_stroke_opacity_folds_into_its_colour checks that a
dashed brush stroke isn't folded; it fails without the change.
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.
- Gradient gains `method` (Classic | Perceptual; serde default Classic,
written only when Perceptual, so older files open as Classic and Classic
files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
sample_keep_model for the stroke meshes, Expand and blends); opacity stays
linear. Renderers that mix linearly get stops on the perceptual curve,
placed by halving each half segment where a straight line strays more than
0.4 of a colour level (a nearly straight segment needs none). Classic
gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
Perceptual fill or stroke as an image at the document's raster effects
resolution, clipped to what it paints. A gradient along or across a stroke
stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
noise (the canvas's dithered grids are keyed by method too); SVG and PDF
write it as an image with the noise, and a dithered Perceptual stroke as an
image without it, as the canvas draws it (GradientPaint::pixels, SVG's
raster_paint and PDF's area are told whether they paint a fill).
Review follow-up (CodeRabbit): a dithered fill skipped set_paint's guard for
a gradient without stops and sampled a transparent grid to draw nothing. It
now takes the same no-fill path.
Review follow-up (CodeRabbit): a large dithered gradient mostly off-screen
sampled its whole grid (up to 4096 x 4096 cells, 64 MiB) to draw a small
visible part, and did it again on each zoom step. The renderer now samples
only the cells the render target can show, a cell more each way for the
bilinear mix, snapped out to 256-cell tiles so a pan within them reuses the
grid. Cells keep their place in the whole grid, so the gradient and its
noise are the same pixels as before (GradientPaint::pixels_within).
Illustrator 30.0's gradient Dither, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
a Dither checkbox in the Gradient panel; dither converts the gradient into
pixels with subtle noise, works with gradient fills only, and follows the
Document Raster Effects resolution.
- Gradient gains `dither` (serde default false, written only when on, so
older files open unchanged and new files keep it).
- The renderer draws a dithered linear or radial fill as an image paint: the
gradient sampled a cell per device pixel or finer (quarter-octave steps,
cached per thread), each channel given seeded triangular noise of up to a
level before rounding. Same pixels every run; strokes and undithered fills
draw exactly as before. PNG and other raster exports go through it.
- SVG and PDF write a dithered fill as an image at the document's raster
effects resolution, clipped to the shape (the freeform-gradient path);
characters' fills stay vector gradients in SVG, with a warning.
- paint.editGradient and paint.setFill take `dither`; strokes refuse it.
The Gradient panel's Dither box is dimmed behind the Stroke proxy.
- i18n rows in all 24 catalogs; docs: G13, target-app-parity, ui-parity,
mcp.md, plugins.md.
File › Place with Link kept a link only to raster images and VectorCraft
documents. A PDF, .ai or .ait page was always embedded as a group, so
Update Links never saw a newer version of the file.
A new preference, File Handling › Link Placed PDF and .ai Files
(linkPlacedPdf), is off by default. With it on, Link places the page as
a placed document, as it places a VectorCraft document: the object
keeps the file's bytes, a preview, the page number and the page's box
(crop, art, trim, bleed or media, stored as page_box). Links check,
update and relink it, Break Link and Object › Expand give the group
placing it without Link gives, every output draws the page's vectors,
and saving and reopening keep the link. With the preference off, or
with a password or textAs: "outlines", the page is embedded as before.
EPS and PostScript .ai files are still embedded.
Package copies a placed PDF or .ai file as it is. The Place dialog's
Link tooltip describes a PDF or .ai file as a document while the
preference is on. Relinking a placed document to a file that is not a
VectorCraft document, a PDF or a PDF-compatible .ai file says so.
Docs: docs/mcp.md, docs/target-app-parity.md.
- One budget for the whole read (layers, pixels, characters, table
cells), shared with embedded documents and charged also when their
reading fails; the first reading is dropped before the second.
- Tables: at most 65 536 cells; lines and cell formats that don't
match the grid are left out instead of shifted.
- Paragraph formats matched to runs by binary search; font names
longer than 256 bytes are dropped.
- Overlays: one work budget per import; encoded originals decoded
within their layer's size; table cell outlines recoloured too.
- Engine: master page copies capped, each page's master found in
linear time, line gaps looked up once per font, the working CMYK
table built once, objects past MAX_COORD left out.
- Embedded content stays with its node; pixel masks under artistic
text are read whole and include stroke reach; gradient overlays on
hidden layers are kept.
- Render: contexts and raster effect images stay within
MAX_RASTER_SIDE per side (vello overflowed near u16::MAX; found by
the new fuzz target).
- Tests: affinity tests/import_limits.rs, feature samples in
import_fuzz.rs, shared builders in engine/tests/support/.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review feedback on #3498: dimmed neighbours share one opacity composite
before the centre tile and one after, preserving overlap paint order
without per-copy compositing.
Co-authored-by: nrq6993-a11y <nrq6993-a11y@users.noreply.github.com>
## Summary
In pattern editing mode, preview copies were drawn in row-major order and the
centre tile was always composited on top, so Pattern Options Overlap had no
visible effect when negative spacing made tiles overlap (as in Illustrator).
Preview copies now paint in the same order as tiled pattern fills.
## Tests
- vectorcraft-doc: preview_paint_order_respects_overlap
Closesstorytold/vectorcraft#3480
Co-authored-by: nrq6993-a11y <nrq6993-a11y@users.noreply.github.com>
With image mips on (the canvas always sets them), drawing an image
fetched its full decoded pixels first, decoding them again when the
shared image store no longer held them, and only then looked up the mip
it draws. The full pixels are only peeked, so they are the least
recently used entries and the first the store drops over its budget
(1 GiB on macOS and Windows). Once a document's full-size images exceed
it (20 photos of 6000 x 4000 px are 1.9 GB as RGBA), every frame decoded
every image again, though only their small mips were drawn: moving one
image redrew seconds later.
The store now remembers the size each blob's pixels decode to, so the
mip level is found without the pixels, and a mip the store holds is
drawn (and marked used) without fetching or decoding them. Without the
mip, at full size, or with mips off (exports) drawing is unchanged.
Measured on an Apple M4 (load average 15-24 from other work), 20 noise
PNGs of 6000 x 4000 px fitted in a 2880 x 1800 view, one moved 5 pt per
frame: before, 20 decodes and 1.1-3.0 s every frame; after, the first
frame decodes 20 and the later ones none, in 3-5 ms.
Include guide visibility in effect raster cache inputs, skip hidden guide paths in outline members, and clear selected guide paths when hiding guides. Add regression coverage for cached effects and selection overlays.\n\nThe cached-effect regression test is adapted from the overlapping #3264 implementation.\n\nCo-authored-by: Mahtab <smmalam9@gmail.com>
A CMYK JPEG came out with JFIF's APP0 segment at its head and four components
in the frame:
APP0/JFIF APP14/Adobe APP2[ICC_PROFILE 1/4 … 4/4] SOF0: 4 components
JFIF describes one- and three-component images, greyscale and YCbCr, so an APP0
segment in front of a four-component frame says two different things about the
file's colours: a reader that trusts JFIF decodes nonsense, and one that checks
refuses the file. Adobe's own CMYK JPEGs carry the APP14 marker the encoder
already writes, and no JFIF. `jpeg-encoder` writes JFIF unconditionally, so the
segment is replaced here.
The resolution JFIF carried moves to an EXIF APP1 segment -- `XResolution`,
`YResolution` and `ResolutionUnit` -- which is where `cmd::fileio::ppi` already
looks when a file has no JFIF, so nothing is lost; with no resolution asked for
the segment is simply dropped. RGB and greyscale files keep JFIF, which is what
it is for.
Found while looking at #3084, whose default RGB exports are valid here; this is
the file that is not.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.