CodeRabbit findings #58, #75, #82, #113, #159, #175, #187:
- Live evaluation (expansion, envelope mapping and everything inside) makes at most 4M
objects (`MAX_GENERATED`); deep expansion counts what each live object makes, and envelope
image pieces count before they are cut.
- Clipping runs copy at most 8M clip path elements together; past it runs are plain groups.
- Exports read at most 256 placed documents; the rest show their previews.
- Graph design and marker budgets count opacity-mask art (`Node::count_with_masks`).
- Native files read at most 32M anchors (4M on wasm) of paths; the web build allows 1 GiB of
entries after the JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chain of geometry effects makes at most 2 million anchors of a path (of a
type, symbol or image art's paths together) and about 8 million lines for the
renderer to draw at 1 px/pt, or what the path costs when that is more.
Roughen spaces its points further apart, Zig Zag makes fewer ridges a
segment, Free Distort, Twist and the warps split segments into longer pieces
(map_nonlinear_within) and Transform makes fewer copies where they would pass
it; a result drawn with too many lines is built again with fewer points, and
an effect that still doesn't fit is left out.
Three Roughens of a rectangle in a 25 KB file needed gigabytes of geometry,
and 2 million anchors of them flattened into 141 million lines (7 GB, past
vello's 2^27 lines a path); the file now opens in 0.4 s at about 450 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GradientMesh::remove_point_lines (the Mesh tool's Alt-click,
object.mesh.deletePoint) only checked that the clicked point exists,
then indexed the points above and below or left and right of it by the
mesh's rows and columns. A gradient mesh read from a document is not
checked against its rows and columns, so one with fewer points than
they need (rows 2, columns 1 and 3 points instead of 6) indexes past
the end and panics: the engine guard rolls the command back with an
internal error, and the web app, which has no guard, aborts.
It now returns false for a mesh that isn't valid, as locate (and with
it adding mesh lines), the outline and the mesh lines already do, and
the command reports that nothing could be deleted.
The test Alt-clicks an interior row's point and an interior column's
point of two such meshes and checks nothing is deleted and the mesh is
unchanged.
Reported-by: BS.ehlas.cz
GradientMesh::is_valid and the two mesh envelope checks (envelope_grid
and the envelope's surface) compare the number of points with
(rows + 1) × (cols + 1), multiplying the document's u32 rows and
columns unchecked. With u32::MAX rows and columns the product is 2^64:
debug builds panic on the overflow and release builds wrap it to 0, so
a mesh or a mesh envelope without points passes as valid. Selecting
it, Outline view, hit testing and export then index points that aren't
there (the mesh lines, outline and edges), tessellating it asks for an
impossible capacity, and a mesh envelope with handles to fill in loops
2^64 times making them until memory runs out. On wasm32, where usize
has 32 bits, 65,535 rows and columns are enough, and a panic there
aborts the web app.
The three checks now share grid_len, which counts the points with
checked arithmetic and gives None when the count doesn't fit in a
usize: a grid that can't be counted matches no point list, so it is
malformed like any other wrong count.
The test checks that a u32::MAX × u32::MAX mesh without points isn't
valid and has no lines, outline or quads, that envelope_grid refuses
those counts, and that a mesh envelope with them leaves art in place.
Reported-by: BS.ehlas.cz
A PDF of thousands of gradient meshes in a CMYK document with transparency
took minutes to export (186 s at 72 ppi for an 18,577-mesh page). Each mesh
was cut into quads coloured in RGB, and drawing a CMYK document by ink planes
separated every quad's colour back to CMYK through the profile, twice: the
4,096-colour separation cache, flooded by the meshes' interpolated colours,
cleared over and over.
- doc: GradientMesh::color_at mixes corners that are all CMYK (or all grey)
in that space, as a PDF shading mixes in its own: exact inks, nothing to
separate.
- render: the separation cache holds 65,536 colours.
- render: a mesh's quads are filled into one image by scanline (every pixel
to the quad its centre is in: no seams) and drawn through the mesh's
outline, instead of one path per quad; meshes over 16 MP on screen are
drawn quad by quad as before, backed by each opaque patch's outline (#2456).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Intertwine group keeps its objects as they are, in their own stacking
order; each member can be drawn over the others in an area of its own.
The group draws, hits, saves and exports through its live expansion, so the
members stay editable and Release gives them back unchanged.
object.intertwine.arrange picks an overlap area by a click point or by a
closed path drawn around overlaps and toggles which member is on top
there, or brings a member to the front or sends it to the back where three
or more objects overlap.
Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.
An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.
Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.
The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.
Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.
Docs: docs/mcp.md, docs/target-app-parity.md.
warp_point worked on box coordinates normalised to -1..1 on both axes:
Arc's radius was in x units but its edges were offset in y units, so a
box that isn't square bent into no circular arc (skewed sides from
Bend 10 %), and at 100 % the radius (2/π) was smaller than the
half-height, folding the inner edge over. warp_point now takes the
box's aspect along the warp; Arc keeps the centre line's length on a
circular arc in real proportions, with concentric edges, and an inner
edge that would pass the centre stops there. Other styles are as they
were; a square is bent as before.
Fixes#1578
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
# Conflicts:
# crates/engine/src/cmd/graph.rs
# crates/svg/src/export.rs
Every edit of an object copied its whole appearance (its fills, strokes and effects, about
540 bytes for a basic object) into the new node, though a move leaves it as it is. Over 45
whole-selection moves of 10,000 paths, undo history grew by 589 MB; now 349 MB (41 % less), and
a nudge or a drag of the selection takes 12 ms instead of 15.
- Node::appearance is a vectorcraft_doc::Shared<Appearance>: copy-on-write. A clone shares it,
and the first change through DerefMut copies it (Arc::make_mut). Reads, writes, equality (also
against a plain Appearance) and the saved form are those of the Appearance itself, so call
sites read and change it as before. Sites that build or assign a whole appearance add .into(),
and the few that need a plain copy take (*n.appearance).clone().
- Node::transform changes the appearance only when it has gradients to transform, and pattern
paints are transformed only when there are some: a move no longer copies an appearance it
doesn't change.
The workspace suite (5,301 tests) and clippy pass with the change; the doc and engine suites
pass again after the transform guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
Discord feedback: "EPS files are always rasterized: they open as bitmaps, in
other programs as vectors". Every EPS saved by Illustrator 2026 fell back to
its TIFF preview (`gcheck` was unknown), and cairo/Inkscape files stopped at
their first image (`status`), dropped masked images (ImageType 3) and failed
on mesh gradients; the warning didn't say where the program failed.
Fallback never silent:
- The warning names the PostScript error, the operator that raised it and
the named procedures it ran in, innermost first ("a PostScript error
(typecheck in `add`), in `inner` in `outer`"); errors caught by `stopped`
aren't reported. It reaches the status bar, document.open, app.open and
file.open (which now return document.open's result, so MCP's open_file over
the control channel gets it), and `vectorcraft-cli info` ("warnings").
- A file whose PostScript can't be read opens as its TIFF preview, else its
Windows metafile preview (as vectors, scaled to the bounding box), else its
EPSI bitmap. es @msg rows for the new messages.
Interpreter:
- Operators programs probe or use: gcheck/scheck, rootfont,
currentcolorrendering, currenthsbcolor, status, token, read,
bytesavailable, fileposition/setfileposition, resetfile, write*, pdfmark
(marks dropped, as Ghostscript does), execform, nulldevice, strokepath,
pathforall, user paths (ufill/ueofill/ustroke/uappend/upath/setbbox, the
encoded form too), setcachedevice2, findcolorrendering, cache, device and
VM queries, revision; (%stdout) (w) file gives a sink.
- `//name` is replaced by its value when read, inside procedures too.
- Tiling patterns become pattern swatches (their PaintProc drawn in pattern
space, one cell; uncoloured ones in the colour under them; makepattern
copies share one swatch); nested patterns stop at 8 levels.
- Type 3 fonts draw their glyphs (BuildGlyph/BuildChar, widths from
setcachedevice/setcharwidth) as outlines grouped per string, spaced by
ashow/widthshow/awidthshow/xshow/yshow/xyshow/kshow; glyphshow and
stringwidth/cshow use them.
- Shadings of types 1 and 4-7 become gradient meshes (data from arrays,
strings or files, packed or not, flags sharing edges), as fills through a
shading pattern too; sampled functions (type 0) are evaluated;
GradientMesh::coons/triangle are shared with PDF import.
- ImageType 3 masked images, interleaved by sample, by row or in separate
sources; ReusableStreamDecode reads its data when made; Flate/LZW PNG and
TIFF predictors.
- Bounded: DeviceN at 32 inks, sampled functions at 2^24 samples and 32
outputs, 4096 patches a shading, strokepath ignores dashes finer than 1e5
pieces, Type 3 glyphs nest 8 deep (a glyph that shows itself overflowed the
stack before the limit), 64-bit index maths for 32-bit targets.
Tests: a synthetic program per generator (cairo/Inkscape, its Type 3 fonts,
matplotlib, CorelDRAW, Affinity, LibreOffice, Ghostscript eps2write,
Illustrator procsets), meshes and function shadings, masked images, fault
messages, probing operators, metafile/EPSI previews, `//name`, user paths,
hostile patterns/glyphs/shadings (run against main, 12 of the 14 generator
and feature tests fail; CorelDRAW- and LibreOffice-style programs already
read there). The import fuzz vocabulary covers the new operators. Checked
against real files made here with Inkscape 1.4 (cairo) and Illustrator 2026
(its own format and the CS-to-CC compatibility formats; all fell back or
stopped early on main and open as vectors now; its Illustrator 8 format
needs the next commit); those files aren't committed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Edit Contents: while an envelope's contents are edited, clicks hit the
content where it sits undistorted and the Selection tool selects it; the
menu item reads Edit Envelope then. No default shortcut: the reference
app's (Shift+Cmd+V) is Paste in Place here.
- A selected envelope shows its mesh (a warp's surface grid, a mesh's own,
a top object's outline) instead of its content's outlines.
- Mesh envelope points get bezier handles (append-only; none stored is the
smooth Catmull-Rom mesh, which the first handle edit fills in exactly
along the grid lines). setMeshPoint takes `handle`; object.mesh.movePoint,
addLine and deletePoint edit mesh envelopes too. The Mesh tool and Direct
Selection drag points and the clicked point's handles (shared MeshEdit);
the Mesh tool adds rows and columns inside mesh envelopes. Reset with
Mesh keeps the shape along the grid with handles; Release returns the
exact mesh.
- Control bar: Edit Envelope / Edit Contents, warp style, orientation,
bend and distortions or mesh rows and columns, Reset, Envelope Options.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Images distort as a raster mesh warp: cut by Fidelity into triangles,
each a clipped image under the affine map of its corners (pieces overlap
slightly, no seams; the renderer paints them without clip layers; SVG
writes the pixels once and <use>s them).
- Symbol instances distort as their symbol's art (the renderer's cache
follows edits to the symbol).
- Distort Appearance bakes strokes (filled outlines) and geometry effects
before bending; Distort Linear Gradients bends linear gradients; Distort
Pattern Fills bends the tiles. Opacity masks of the content bend too.
- Bug fix: a rotated, sheared or reflected warp envelope re-warped along
the page axes. Envelopes now keep their own frame (append-only model
field), so the warp turns with the art; move and scale keep it square.
- Live evaluation shares one set of hooks (vectorcraft_doc::live::Hooks,
vectorcraft_effects::{expand_live, expanded_live_group,
expand_live_deep}) between the canvas, Expand and every exporter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closed shapes start where they twist least (unless the Blend tool
picked the anchors); dashes, miter limit, arrowhead scale and width
profiles interpolate (caps, joins and brushes switch halfway);
gradients with different stop counts resample to common stops; groups
with different member counts grow the extra members out of a point;
compound steps stay compound paths; type, symbol instances and images
interpolate their transforms, type its colours and sizes run by run.
New blends are knockout groups and the canvas draws them knocked out
as the exports do. Expand and Release keep the blend's name,
transparency, opacity mask and appearance, and Object > Expand expands
blends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A selected blend shows its spine. Direct Selection drags spine points
and handles, the Pen tool adds and deletes points on a selected
blend's spine (object.blend.spine.moveAnchor/addAnchor/removeAnchor).
The straight spine becomes a path on the first edit, its points pinned
to the key objects (append-only BlendSpec.key_anchors): moving a key
moves its end of the spine and the other way round; spines from older
files and Replace Spine still spread the keys evenly. Direct and Group
Selection pick blend keys on the canvas, edits redraw the blend live,
and Release leaves the spine as a path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blend Options is a dedicated dialog (Spacing dropdown with its value,
Align to Page / Align to Path buttons, live preview) opened on the
selected blend's own options, so OK no longer turns Smooth Color into
5 steps; with nothing selected it sets what new blends start with
(object.blend.info reads them). The Blend tool blends from clicked
anchor points (blend spec `starts`), adds keys to an existing blend
instead of nesting it, opens Blend Options on double-click, Alt-click
and its toolbar button, and has its own cursors and hint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Make with Warp and Make with Mesh open dedicated dialogs (style dropdown,
orientation, bend and distortion sliders; rows and columns) with live
preview; while an envelope is selected the menu shows Reset with Warp /
Reset with Mesh instead, which switch the envelope's kind and keep its
content (Maintain Envelope Shape samples the current surface). Envelope
Options is a full dialog prefilled from the selection (Anti-Alias,
Preserve Shape, Fidelity, Distort Appearance, Distort Linear Gradients,
Distort Pattern Fills); with nothing selected it sets the defaults for new
envelopes. object.envelope.info reads the settings. Release returns the
warp or mesh shape as a gradient mesh and keeps opacity, blend mode and
mask; Expand keeps name, knockout, isolation and mask; Object > Expand
expands envelopes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shipped code no longer unwraps, expects or panics: every one of the 124
unwrap()/expect()/panic!/unreachable! sites outside tests now handles the
failure (Result/Option, ?, let-else, defaults) and reports errors to the user.
- Lints: workspace clippy denies unwrap_used, expect_used, panic, todo,
unimplemented and unreachable; clippy.toml allows them in tests, and
integration tests, examples and testkit opt out explicitly.
- Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls
the document back and returns EngineError::Internal; a panicking tool is
reset; the MCP server answers with an internal error and keeps serving; the
UI frame and each control request are guarded.
- APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp
return Result (JPEG/WebP errors used to write empty files),
FontDb::face returns Option, Region::top returns Option.
- Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG
and PDF through import → render → export (30k cases locally, no panics).
- Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section,
README pointer and a PR template checklist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radial gradients show a dashed extent ellipse: its handle across the bar
sets the aspect ratio, the ellipse elsewhere rotates it, and the dot inside
the centre ring drags the focal point (GradientGeom.focal) for an off-centre
radial. paint.setGradientGeom takes aspect (%) and focal ([x,y] | null), and
start/end may be left out; the focal point keeps its place in the ellipse as
the vector, aspect and object transform. It renders as a two-point radial,
exports and imports as SVG fx/fy and PDF two-point radial shadings, expands
to off-centre rings, and travels in gradient params and the native format.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Colours gain a CIE Lab model (`Color::Lab {l,a,b}`, serde tag `lab`), handled through the
CMS (display, separation, conversions, gamut), tints (toward paper white), keep_model, harmonies,
recolouring, blends, Info and the Color panel. Swatch Options gets a Lab mode
(`swatch.edit {mode: "lab"}` / `{color: {l,a,b}}`), and colour params accept `{l,a,b}`.
Swatches panel → Spot Colors… (`ui.spotColors`, dialog `spotColors`) runs `swatch.spotOptions
{useLab}` (Document.spot_use_lab, default on): Lab spot colours show and separate from their Lab
values, or art linked to them takes their working-CMYK equivalents, as one undo step. PDF export
writes Lab spot inks as Separation spaces with a Lab alternate (tint function from paper white),
or a DeviceCMYK one with CMYK equivalents.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Object > Expand… opens a purpose-built dialog (Object, Fill and Stroke
checkboxes, disabled when the selection has nothing for them; Expand
Gradient To: Gradient Mesh or Specify N Objects, default 255). Every
option now does something:
- Object: type to outlines, live shapes to paths, effects baked.
- Stroke: strokes outlined into filled paths.
- Fill: linear/radial gradient fills become N solid rectangles or
concentric ellipses, or a gradient mesh with lines where the colour
changes, inside a clip group shaped like the object; freeform
gradients become a mesh shaped like the object. Colours keep their
stops' colour model. With Fill off gradient fills stay live.
object.expand {} defaults match the dialog (stroke now defaults on);
new query object.expand.info and UI command ui.expandDialog.
Create Gradient Mesh on a gradient-filled object takes the colours the
gradient paints at each point (GradientMesh::for_path_with, from_grid,
highlight).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New Node.knockout_shape (transparency.set / object.setProps knockoutShape,
reported by transparency.info). In a knockout group such an object knocks
out only as much as its opacity and mask let through (a 50% object knocks
out 50%); otherwise its full coverage knocks out. The renderer, PDF and
SVG masks honour it, and the panel's checkbox is now live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>