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.
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.
When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
Colour emoji came out as the placeholder rectangles their outlines carry
(Apple Color Emoji) or as .notdef when the face has no outline at all:
nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for
colour ink before its outline: an embedded bitmap strike decoded to
premultiplied RGBA and placed by the strike's bearings the way Skia (and
CoreText) places it, scaled to the size the run asks for, or a COLR v0
glyph's layers filled from the font's palette.
Faces that carry only colour tables are indexed again: Apple Color Emoji
is no longer skipped for being 190 MB (the scan still reads headers and
name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join
"Apple Color Emoji" as colour fallbacks, read only for a text that
nothing else covers. The png crate decodes the strikes; skrifa keeps
shaping, metrics and outlines.
Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and
PDF exports (text is outlines there).
Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding,
scaling, COLR layers and bitmaps of the machine's own colour font) and a
render case gated on that font; the font-free ones run everywhere.
Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji
(CBDT) read from a downloaded file; the sbix path runs through the same
code and needs a macOS run to be seen.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Boolean operands restarted subtree_outline at depth 0, so booleans nested
in booleans bypassed the 64-level limit and could overflow the stack.
Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes#208.
Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.
Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>