A mask group's pixmaps cover only where its mask or content can paint within the view, rounded out to whole pixels, drawing the same pixels (a 40x40 mask on a 3000x2000 view: 96 MB -> 61.5 kB). Isolated layers share a 2 GiB per-thread budget; a layer past it is left out with a warning.
* M8: large images on the canvas draw from a smaller copy (#1483)
An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored).
* M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483)
The header check from #1491 moves to figcraft_format::jpeg so the format crate and the renderer share it, and decode_thumbnail now refuses a progressive JPEG that samples a component more finely than its first (zune-jpeg 0.5.15 asserts on it; in the browser a panic aborts the page). A crafted file's thumbnail on the home screen and in recent files shows no picture instead of crashing. Regression test through decode_thumbnail and archive_preview.
* M3: shadows of images with transparent pixels follow their alpha (#1289)
A drop shadow on an image fill was hidden behind the layer's whole box, so a
transparent PNG's shadow only showed outside its bounding box. Fills that
paint their own alpha (patterns; images with transparent pixels or that leave
part of the box empty) now cast and hide drop shadows, and shade inner
shadows, by the layer's pixels, like groups. Opaque pictures keep their
outline. Each image is scanned for transparency once. Canvas, PNG, SVG and PDF.
* M3: the image shadow tests import their names explicitly (#1289)
From the review of #1491: patching a component's sampling in the header of a baseline JPEG
`image` wrote leaves its entropy data for 1 × 1 components, so decoding it could only fail and
the test proved nothing past the frame not being refused. The fixture is now a baseline CMYK
JPEG as jpeg-encoder writes it with 2 × 2 sampling (its black sampled finer than its cyan), and
the test asserts it is left to the decoder and decodes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler on a progressive frame
whose later component is sampled more finely than its first (etemesi254/zune-image#236; an 8 × 8
Y 1×1, Cb 2×2 file is enough). The panic reached everywhere such an image is decoded: the CLI died
writing a thumbnail, the desktop canvas's guard caught it on every render of the page, and in
the browser it aborted the tab. The fix in zune-jpeg (36007420) is in 0.5.16 pre-releases only,
which `image`'s requirement doesn't take.
Such a JPEG is now refused from its frame header before the decoder sees it, as an image past
the byte budget is. The decoder refuses the larger ones of this shape itself, so nothing that
decoded is lost; baseline frames of the same shape decode and are left to it. The check can go
once zune-jpeg 0.5.16 ships.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Shadows and layer blurs dominated PNG/JPG/WebP export time (gdb stack
sampling of the example files: about 45% in fx::box_blur_h, 13% in
fx::morph). Both now produce the same bytes, faster:
- fx::blur runs its three box passes per row while the row is in cache,
works on whole pixels instead of bounds-checked bytes, skips fully
transparent rows, transposes in cache-friendly column blocks, and
splits rows and transposes across rayon on desktop for pixmaps of
64K pixels and more, only when not already on a rayon worker (a band
of render_view stays on its thread: no nested parallelism).
- fx::morph (shadow spread on layers that aren't one outline) uses the
van Herk / Gil-Werman running max/min: constant work per pixel instead
of 2r + 1.
Integer arithmetic and pass order are unchanged; new tests compare both
against the previous implementations byte for byte, including sizes on
the parallel path.
An ignored timing test exports every top-level frame of examples/ as
PNG, JPG and WebP at 2x through export_file:
cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture
Ryzen, 16 threads, Linux; median of 7 (min):
PNG 2195 ms (2182) -> 992 ms (989) -55%
JPG 2880 ms (2560) -> 1550 ms (1535) -46%
WebP 1964 ms (1901) -> 1022 ms (1018) -48%
total 7035 ms (6677) -> 3567 ms (3555) -49%
All 80 exported files (PNG, JPG, WebP, PDF, SVG) are byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retain regional paint stacks and coordinate frames through simplify, offset, flatten, bucket normalization and Shape Builder. Preserve legacy fill semantics and synchronize importer warning catalogs. Verified with cargo xtask ci on Windows and Ubuntu/WSL, native UI screenshots, and CLI round-trip/export checks.
Zoomed in on an image, the canvas smoothed it with bicubic sampling, so
a screenshot under inspection went soft at every edge and its pixels
could not be told apart. Checking a screenshot of a live page against
its design up close needs the pixels as they are.
The canvas now samples an image paint at the nearest pixel once each
image pixel covers two or more device pixels. Below that the image stays
smooth: between 1x and 2x nearest sampling draws pixels one and two
device pixels wide side by side, which looks jagged rather than sharp.
Exports, thumbnails, PDF and SVG are unchanged: the switch is a render
option, magnified_image_pixels, that only the canvas sets, and the
retained renderer reuses a render only when it matches. Pixel preview
was not a fit: it rasterises the whole canvas at 1x or 2x, so vectors
and text go blocky along with the image.
* M12: boards dot grid, re-attaching connector ends, curved connectors
Board pages draw a dot grid in the canvas render, after the page colour
and under the layers (figcraft_render::Dots), thinned out when zoomed out
and coloured from the theme's ink against the page. A selected connector
shows its ends as handles; dragging one onto an object re-attaches it,
onto empty canvas leaves it free (board.reattach, one undo step, also
over MCP). Connectors can be curved, and elbows go around the objects
they join when the short way would cross them.
* M12: boards dot grid: blending a dot in a function of its own (Sonar)
One side_strokes helper (square or rounded, as #1044 paints them) serves stroke_area and the boolean
operand outline, instead of a second, square-only copy; test for a rounded per-side border.
Found while testing crates/pathops/src/lib.rs:326-333 (stroke_outline_of, StrokeAlign::Inside and Outside arms calling binary(), which uses FillRule::NonZero at :82)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
stroke_area used rect_side_strokes only for square corners; a rounded
rect-like layer with individual weights fell through to a uniform ring at
stroke.weight (canvas, SVG, PDF, shadows, shape builder, outline stroke).
pathops::rounded_side_strokes now draws them as CSS does: the outer edge is
the rounded rect (the fill outline for inside strokes), the inner edge is
inset by each side's weight with elliptical corners max(0, R - weight), so
border-l-4 rounded-r-lg is a straight bar and a weight that differs across
a rounded corner tapers through it. Center and outside grow the outer box
and each non-zero radius by half or all of the weight (CSS has only
inside). The inner edge runs against the outer one, so the ring is a ring
under non-zero too; where it could leave the outer edge (radii shrunk to
fit a narrow inner box, smoothed outer corners) it is cut out instead.
ExportFormat::VectorDrawable ("XML", .xml) and ExportSetting.vd_compat
(bestEffort / exact / ignore, stored only when not the default) wire the
figcraft-vectordrawable writer into file.export, file.exportLayers and
export.add/update: a vdCompat param (junk refused), svgIdAttribute writes
android:name, and the results carry the writer's warnings (per file for
export layers). The Export section lists XML with an "Android Vector
Drawable" tooltip and its id attribute and compatibility options, File >
Export... gets an XML preset, and the toasts say when content was changed
or left out. CLI: --format xml / .xml outputs, --vd-compat, warnings as
note: lines. MCP: format xml returns the XML as text plus its warnings.
A .fig export writes XML settings as SVG with a note.
Docs: file-format-parity (row and revision), ui-parity and roadmap
revisions, architecture (the crate), cli and mcp.
Node lookups in the document's B-tree were still a third of a render on a
900 000-layer file: a small subtree was walked for its bounds, again to
see whether it could be splatted, and again to splat it, and each child
of a drawn layer was looked up by paint_runs, by the mask check and by
draw_node.
- The render's table also keeps the layers it looked up, keyed with a
multiply hash instead of SipHash (ids are counters), and so do the
bounds it keeps.
- draw_children looks each child up once, works out the paint runs from
those (Document::paint_runs_of) and draws the child it has.
- Whether a layer can be splatted is kept like its bounds: layers with
children only, answers the nesting cap decided aren't kept, and kept
answers are read only where their levels fit under the cap.
Ctx gets a second lifetime, for the document the kept layers come from.
Single-threaded cycles per 1440x900 render fitted to the page: 0.39 ->
0.34 G on a page of 246 204 layers, 0.26 -> 0.22 G on one of 118 358.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
Culling a layer asked for its paint bounds, which walk its whole subtree,
and then did the same for each child: every layer's subtree was walked
again at each level above it, and once more in each parallel band. The
bounds worked out during a render are now kept for it (layers with
children only; bounds the 64-level cap cut short are not kept, and kept
ones are read only where their levels still fit under the cap, so culling
is exactly as before).
Every render also looked through every layer of the document for the
largest background blur, to size the overlap of its bands: 900 000 layers
on one large file, on the main thread before the bands start. The
document now works it out when first asked after a change, like whether
it has masks.
A 1440x900 render on a 900 000-layer file, page of 118 358 layers: at
100% 13.0 -> 0.6 ms, at 25% 16.5 -> 3.4 ms, fitted to the page 56 -> 31 ms;
page of 246 204 layers: 12.6 -> 2.5 ms, 12.1 -> 2.0 ms, 69 -> 62 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
A filled area of a vector network can now carry its own paint stack. A region without paints of its own keeps drawing with the layer's fills, and a region with an explicitly empty list draws nothing, so the two cases stay distinguishable. The canvas, SVG and PDF output, boolean and hit-test geometry, vector editing and region operations all honour the per-region paints and keep them when regions are split, merged or reordered.
The .fig importer resolves the individual paint stacks of a layer's filled areas through its normal paint mapping and hands them to the document builder as private mapper data. Public REST vectorNetwork JSON never consumes that data and its region paints are dropped, so only validated .fig input can set them. Invalid entries are skipped with an import note. Live style and variable links on individual regions and writing region paints in the experimental .fig exporter are not part of this change.
The canvas renderer and PDF export stroked text with the alignment
forced to centre, so Inside and Outside on text did nothing (SVG export
already honoured them). Glyph outlines are closed, so text strokes now
go through stroke_area like other shapes' strokes, with the same fill
rule: inside clips to the glyphs, outside to around them.
A glass layer's coverage, distance field and normals depend only on its
outline and the zoom. A shape asked for again in a later render (the
next frame of a drag, a slider drag, a canvas patch, an export of the
same view) is now worked out whole, in its own pixel frame (translation
reduced to its fraction, in 1/65536 px with carry), and kept: renders
reuse it instead of redoing the distance transform and normals for
their crop.
A shape asked for only once (each step of a zoom, a pane dragged by
fractions of a pixel, a resize) is still worked out for just the part
shown, so those don't get slower: working out the whole pane when only
a corner is in view cost 1.8x before this rule.
The cache is bounded in bytes (128 MB, 32 MB on the web), keeps the
least recently used last, skips shapes over 4M px (1M on the web), and
parallel bands share one computation (OnceLock slot). Renders count
once per render_view / render_view_single; bands don't.
Output: digests of 30 renders (5 looks, square and rounded, scales 1,
1.37, 2) are identical to main; a cut through a rounded pane now equals
the whole render exactly.
Release timings (glass_render_timings, 2400 × 1600 px, min of 5):
canvas drag under the glass 49.5 → 26.5 ms, glass slider drag
104.8 → 61.6 ms, same view again 94.8 → 54.0 ms; zoom 105.0 → 93.7 ms
(no reuse: each zoom is new).
Refs #256
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Where the 512 px cap held the light short of half a level, it dropped to 0
in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300,
splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap
doesn't bite this moves the light by at most half a level.
Cut tests now also cover rounded corners and a look under the cap.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Where nothing bends (past `depth`, or with no refraction) the three
colour channels sample the backdrop at the same point: one sample
instead of three. The rim light's facing and powers are worked out only
where the light reaches. Same bytes: an ignored `glass_digests` test
prints digests of 30 renders (5 looks, square and rounded, 3 scales),
identical before and after this change (and to main).
Refs #256
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The glass light falls off with the distance to the edge, read past
`depth` (up to ~3 × depth at full splay), but the coverage window around
a cut layer was only `depth + 2` wide. With frost and refraction at 0
the backdrop margin is 0 too, so a band or canvas patch edge running
through the pane changed the light near it: up to 12 levels between a
banded and a whole render.
The window now covers the light's reach (where it falls under half a
level, clamped at 0 and capped at 512 px) plus the span the normals are
smoothed over, and past that reach the light is 0 instead of whatever
the window happened to hold.
Tests: a cut 50–80 px inside the pane equals the whole render byte for
byte (fails on main by 12 levels); light_reach edge cases. Adds an
ignored release timing test (glass_bench_tests).
Refs #256
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.