Commit Graph
130 Commits
Author SHA1 Message Date
BFlat dd59ea9eb4 Merge pull request #1796 from samhyun/fix/dots-half-pixel
M8: dots halfway between pixels stay put across pans, bands and patches
2026-11-07 12:07:14 +08:00
BFlat 1e1470400a Merge pull request #1847 from samhyun/fix/dashed-spread-reach
M8: a spread round a stroke with corners keeps its whole shadow
2026-11-07 11:55:25 +08:00
BFlat e9fc975aa6 Merge pull request #1756 from dotCooCoo/feat/vector-network-corner-smoothing
M4: closed vectors take corner smoothing
2026-11-07 10:27:00 +08:00
BFlat 5762212ced Merge pull request #2006 from JonRC/fix/jpeg-quality-srgb-icc
M6: JPG exports take a quality and embed the sRGB profile (#2004)
2026-11-07 09:47:15 +08:00
BFlat 049772335b Merge pull request #2057 from aleksei-suholita/perf/glass-dispersion-narrow-window
M8: glass takes one sample where its colours split under 1/256 px
2026-11-07 08:53:12 +08:00
Oleksii Sukholita 3d41394bfc M3: glass's docs say which centre splay bends away from and where a shape gets no core
Splay bends away from the centre of the layer's bounds, as the reference
tool's does: on a ring or an L that centre lies outside the shape, so an
inner edge bends towards the hole. Checked side by side on a ring and an
L at Splay 0% and 100%. The module doc still said splay 0 bends along the
edge's normal; it bends towards the core since #1889.

`MAX_SHAPE_PX` now says it is 4M px on the desktop and 1M on the web, and
that a bigger shape gets no core, so a large glass text or vector can bend
differently once zoomed into, in a 2× export, or on the web.

Docs only.
2026-11-06 20:29:14 +01:00
Oleksii Sukholita 43403b3fed M8: glass takes one sample where its colours split under 1/256 px
The dispersion window samples the backdrop 18 times a pixel wherever the
curve bends it, even where the move is so short that the channels split
by a sliver of a pixel. Under 1/256 px every step lies within that of
the moved point, so one sample is within one level (of 255) of the window
over any backdrop, the hardest being 1 px columns of black and white or
of white and nothing. Those pixels now take one sample.

A larger split doesn't qualify: at 1/4 px one sample would move red and
blue by 1/8 px, up to 32 levels across a hard edge.
2026-11-06 19:56:36 +01:00
8eb0b87646 M3: glass bends towards its core, and rounded rectangles work it out in closed form (#1889)
* M3: glass bends with the reference tool's thick-lens profile and splits its colours over 8 steps (#1885)

The backdrop bends most a 1-unit bevel in from the edge, falls off as the
3.5th power of the way out of the curve to nothing `depth` further in, and
moves a fifth as much on the bevel itself (it fell off as the square of the
way in from the very edge). Dispersion samples each channel at 8 steps along
the bend with a triangular window, red moving furthest and blue least (it was
three samples, blue furthest), as far apart as the reference tool's: at 100%
red and blue move 0.16 of the bend apart (`Glass::DISPERSION_SPREAD`).
`Glass::max_bend` bounds red's furthest step, so the backdrop read around a
pane covers every sample, and the field margin covers the bevel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's bend stays within `max_bend`, checked on a single bright pixel too (#1885)

Review follow-up: the bend reaches `depth` × `refraction` a bevel in from
the edge, so `Glass::max_bend` drops its 0.999 and bounds it, as a test now
checks for several refractions. A single white pixel on black pins the
dispersion's triangular window, which a ramp and a flat backdrop can't tell
from one centred sample. The parity assessment records the bend, the
dispersion and how they were measured against the reference tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's light is a line on its bevel, and splay turns the bend towards the centre (#1885)

The light is a line on the 1-unit bevel added to the backdrop, as the
reference tool's: fully where the edge faces the light, none below 0.35 of
facing it and 0.6 as much on the opposite side, with a faint gradient across
the curve, 5% darker facing the light and lighter opposite. It was a glow
mixed towards white, as deep as `splay` made it, so `light_reach` goes.

`splay` turns the bend from along the edge's normal towards the way away
from the layer's centre, and no longer widens the light: files with a splay
above 0 look different. The direction to the centre is worked out in the
shape's own frame (`own_frame`, shared with the field cache), so cut renders
match the whole one byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass bends towards its core, and rounded rectangles work it out in closed form (#1885)

The backdrop bends towards the glass's core, the shape shrunk by `depth`
and the bevel, instead of along the smoothed edge normal: round a sharp
corner the bend turns smoothly instead of meeting along the diagonal, and a
part thinner than twice that (a capsule, a letter's stroke) bends across
itself towards its own middle, pixels about as deep as any within 2 px. The
fields keep a bend direction beside the normal (an exact feature transform
from the core, box-blurred once), their key includes the core's depth, and
their margin covers the core.

Rectangles, frames, components, instances and sections whose corners aren't
smoothed, under a rotation, flip and even scale, work their glass out in
closed form (`AnalyticRect`): signed distance, the light's normal (corners
rounded to at least the bevel) and the bend direction (corners rounded to
the core) per pixel, in the layer's own frame, with nothing to rasterise,
transform or keep. A zoom frame draws in 40 ms instead of 54 and a resize
frame in 46 instead of 67 (`glass_render_timings`, release, 2×).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: cut renders of glass match with splay turning a dispersed bend (#1885)

Review follow-up: the cut-versus-whole test had splay only on looks without
refraction, so no turned bend reached the dispersion. A look with splay 1 and
the default bend and dispersion now renders the same however it is cut. The
parity assessment records the light on the bevel and splay's new meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: a glass too big to keep bends along its normals, and the core's fields take less memory (#1885)

Review follow-up: a shape that doesn't use the closed form and whose whole
window at the zoom, with the core's margin, would pass `MAX_SHAPE_PX`
(a large vector, text or smoothed rectangle, or a deep glass far zoomed in)
bends along its edge normals instead of working out a core, with the plain
margin. The choice depends on the shape and the zoom alone, so every band and
patch of a render agrees, and no render works out a core over more pixels
than a kept shape holds. The core's feature transform keeps `u32` indices
(16 bytes a pixel) and drops its maxima and core before making the
directions.

The edge normals are box-blurred twice again and the bend directions once,
as their docs say: the two were swapped.

The parity assessment records the bend towards the core and the closed form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's flat inside skips the bend's curve, and full dispersion stays within `max_bend` (#1885)

Review follow-up: `bend_at` returns 0 straight away past `depth` + the
bevel, where the curve and the bevel's easing are both 0, instead of
working out a power of 0 for every pixel of a pane's flat inside. A test
moves one white pixel along a row under every bend of the profile at full
dispersion and checks that no channel ever reads it further out than
`Glass::max_bend`, which sizes the backdrop read around a pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's light shows over a transparent backdrop, and its flat inside skips the light (#1885)

Review follow-up: the light was added to the backdrop's colour and then
clamped to the backdrop's alpha, so with nothing under the glass (an
export of the pane alone) the line on the bevel vanished, where the old
glow raised the alpha and showed. Now the alpha rises as far as the lit
colour needs (`lit`), and stays the backdrop's wherever the light fits
under it, so renders over an opaque backdrop don't change.

Past `depth` + the bevel the rim light, the gradient and the bend are all
0, so those pixels take one sample of the backdrop and skip the light's
direction, facing and splay work, as the old glow's `reach` early-out did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: a kept glass shape works out only its bend again when the depth changes, and a core gets a wider margin (#1885)

Review follow-up. The field cache was keyed on the core's exact depth, so
every step of the Depth slider missed it and worked the whole shape out
again over a window twice as wide: coverage, distances, normals, the
feature transform and the blur, every frame, for every shape that isn't a
plain rounded rectangle. Only the bend directions depend on the depth. The
kept fields (`EdgeFields`: coverage, distances, normals) are now keyed
without it, and hold the bend directions for the last two depths asked
for beside them, so a new depth works out only those (`EdgeFields::bends`).
Two glass layers of one shape at different depths each keep theirs.

The margin a core takes was `reach` + 2 px past the plain one, but the
nearest core pixel can be √2 × `reach` away round a sharp corner, past
what that covers once `reach` is over about 91 px. It is now
1.5 × `reach` + 2 px past the plain margin. The cut-through-the-pane test
gets a 60-unit-deep look (a core 122 px in at 2×).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Glass: splay's doc says splay 0 bends towards the core

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 22:59:27 +01:00
857d7089be M3: glass's light is a line on its bevel, and splay turns the bend towards the centre (#1888)
* M3: glass bends with the reference tool's thick-lens profile and splits its colours over 8 steps (#1885)

The backdrop bends most a 1-unit bevel in from the edge, falls off as the
3.5th power of the way out of the curve to nothing `depth` further in, and
moves a fifth as much on the bevel itself (it fell off as the square of the
way in from the very edge). Dispersion samples each channel at 8 steps along
the bend with a triangular window, red moving furthest and blue least (it was
three samples, blue furthest), as far apart as the reference tool's: at 100%
red and blue move 0.16 of the bend apart (`Glass::DISPERSION_SPREAD`).
`Glass::max_bend` bounds red's furthest step, so the backdrop read around a
pane covers every sample, and the field margin covers the bevel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's bend stays within `max_bend`, checked on a single bright pixel too (#1885)

Review follow-up: the bend reaches `depth` × `refraction` a bevel in from
the edge, so `Glass::max_bend` drops its 0.999 and bounds it, as a test now
checks for several refractions. A single white pixel on black pins the
dispersion's triangular window, which a ramp and a flat backdrop can't tell
from one centred sample. The parity assessment records the bend, the
dispersion and how they were measured against the reference tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's light is a line on its bevel, and splay turns the bend towards the centre (#1885)

The light is a line on the 1-unit bevel added to the backdrop, as the
reference tool's: fully where the edge faces the light, none below 0.35 of
facing it and 0.6 as much on the opposite side, with a faint gradient across
the curve, 5% darker facing the light and lighter opposite. It was a glow
mixed towards white, as deep as `splay` made it, so `light_reach` goes.

`splay` turns the bend from along the edge's normal towards the way away
from the layer's centre, and no longer widens the light: files with a splay
above 0 look different. The direction to the centre is worked out in the
shape's own frame (`own_frame`, shared with the field cache), so cut renders
match the whole one byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: cut renders of glass match with splay turning a dispersed bend (#1885)

Review follow-up: the cut-versus-whole test had splay only on looks without
refraction, so no turned bend reached the dispersion. A look with splay 1 and
the default bend and dispersion now renders the same however it is cut. The
parity assessment records the light on the bevel and splay's new meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's flat inside skips the bend's curve, and full dispersion stays within `max_bend` (#1885)

Review follow-up: `bend_at` returns 0 straight away past `depth` + the
bevel, where the curve and the bevel's easing are both 0, instead of
working out a power of 0 for every pixel of a pane's flat inside. A test
moves one white pixel along a row under every bend of the profile at full
dispersion and checks that no channel ever reads it further out than
`Glass::max_bend`, which sizes the backdrop read around a pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's light shows over a transparent backdrop, and its flat inside skips the light (#1885)

Review follow-up: the light was added to the backdrop's colour and then
clamped to the backdrop's alpha, so with nothing under the glass (an
export of the pane alone) the line on the bevel vanished, where the old
glow raised the alpha and showed. Now the alpha rises as far as the lit
colour needs (`lit`), and stays the backdrop's wherever the light fits
under it, so renders over an opaque backdrop don't change.

Past `depth` + the bevel the rim light, the gradient and the bend are all
0, so those pixels take one sample of the backdrop and skip the light's
direction, facing and splay work, as the old glow's `reach` early-out did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 22:44:38 +01:00
fe3567a56b M3: glass bends with the reference tool's thick-lens profile and splits its colours over 8 steps (#1887)
* M3: glass bends with the reference tool's thick-lens profile and splits its colours over 8 steps (#1885)

The backdrop bends most a 1-unit bevel in from the edge, falls off as the
3.5th power of the way out of the curve to nothing `depth` further in, and
moves a fifth as much on the bevel itself (it fell off as the square of the
way in from the very edge). Dispersion samples each channel at 8 steps along
the bend with a triangular window, red moving furthest and blue least (it was
three samples, blue furthest), as far apart as the reference tool's: at 100%
red and blue move 0.16 of the bend apart (`Glass::DISPERSION_SPREAD`).
`Glass::max_bend` bounds red's furthest step, so the backdrop read around a
pane covers every sample, and the field margin covers the bevel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's bend stays within `max_bend`, checked on a single bright pixel too (#1885)

Review follow-up: the bend reaches `depth` × `refraction` a bevel in from
the edge, so `Glass::max_bend` drops its 0.999 and bounds it, as a test now
checks for several refractions. A single white pixel on black pins the
dispersion's triangular window, which a ramp and a flat backdrop can't tell
from one centred sample. The parity assessment records the bend, the
dispersion and how they were measured against the reference tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: glass's flat inside skips the bend's curve, and full dispersion stays within `max_bend` (#1885)

Review follow-up: `bend_at` returns 0 straight away past `depth` + the
bevel, where the curve and the bevel's easing are both 0, instead of
working out a power of 0 for every pixel of a pane's flat inside. A test
moves one white pixel along a row under every bend of the profile at full
dispersion and checks that no channel ever reads it further out than
`Glass::max_bend`, which sizes the backdrop read around a pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 22:27:51 +01:00
JonRC a55a728c4c M6: JPG exports take a quality and embed the sRGB profile (#2004)
file.export always encoded JPG at quality 90, and an sRGB document's JPGs
carried no colour profile, so viewers could only assume sRGB. PNG exports
always get an sRGB chunk, and Display P3 JPGs already embedded their ICC
profile.

- ExportSetting gets a quality (1 to 100, default 90, saved only when it
  isn't 90), parsed with the other export options: file.export, export.add,
  export.update, file.exportLayers and MCP export take it.
- encode_export_with takes the quality; encode_export keeps quality 90.
  Every JPG now embeds the document's profile, sRGB included.
2026-11-05 00:09:19 -03:00
Dylan M. Taylor b9b64a8755 fix(render): check the nesting limit for mask content and bound GIF frame buffers (#1925)
* fix(render): check the nesting limit for mask content and bound GIF frame buffers

A mask's content is drawn straight from mask_pixels, not through
draw_layer, so masks nested inside masks recursed past the 256-level limit
and down the stack. GIF decoding budgeted frames by the logical canvas but
left the decoder's allocation limits off, so one frame descriptor could ask
for a gigabyte before the frame came back to be checked.

* address review: give the GIF decoder one frame of working room beyond the animation cap
2026-11-03 22:56:28 -05:00
Dylan M. TaylorandBrandon Thomas 8717728a87 perf(color): sort gradient stops once per diamond texture (#1894)
Gradient::sample cloned, sanitised and sorted every stop on each call,
and the diamond texture sampled it for each of its 65 536 pixels, so a
file's gradient with many stops cost that sort 65 536 times.

sample_sorted samples stops already sorted, finding the segment by binary
search; sample delegates to it and the diamond texture sorts once.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:12:58 -05:00
samhyunandClaude Opus 5.5 55821565ac M8: a spread round a stroke with corners keeps its whole shadow
A dashed stroke is cut into dashes with corners wherever they fall round
the outline, and a width profile or a brush can give the stroke corners
of its own. A drop shadow's spread mitres those corners out, but a whole
ellipse or a box still counted as growing by exactly its spread, so the
shadow was cut off at the layer's edge (up to 255 levels).

Such a node now gets the room a star does. Whether the outline's own
mitres end on its box is a check of its own, which the stroke's reach
keeps using, so a node with no spread keeps its bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:21:33 +09:00
김삼현andClaude Opus 5.5 702b0d7894 M8: a whole ellipse drawn backwards keeps its simple stroke in SVG (#1848)
* M8: a whole ellipse drawn backwards keeps its simple stroke in SVG

An ellipse with a sweep of -2π goes all the way round, as the renderer
and the .fig export already read it. The SVG export's simple stroke
checked `sweep >= 2π` only, so such an ellipse's inside or outside
stroke was written as a filled even-odd ring instead of a stroke.

All three places now ask `figcraft_geom::full_sweep`, as the geometry
and the bounds do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: a backwards whole ellipse's stroke ring in the renderer and .fig export, tested

Review follow-up (#1848): the renderer's stroke ring and the .fig
export's stroke geometry read a sweep of -2π as whole too, now through
`full_sweep`. A test for each checks a backwards ellipse strokes the
same ring as a forward one, and fails if either goes back to checking
positive sweeps only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: the .fig backwards-ellipse test compares the stroke ring itself

Review follow-up (#1848): the test checked only that one stroke path was
written. It now compares the path's commands and winding rule for the
forward and backwards ellipse, and that the ring is even-odd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:49:38 -05:00
김삼현andClaude Opus 5.5 264bcb1c03 M8: an arc's spread shadow and mitred stroke get room for their corners (#1802)
* M8: an arc's spread shadow gets room for its mitred corners

Rectangles, frames and ellipses grow by exactly their spread, so their
layers only make room for the spread itself; other shapes make room for
the mitre at sharp corners (up to four times the spread). An ellipse
with arc data (a pie slice, or a ring cut short) counted as an ellipse,
but its ends meet its sides in sharp corners: a spread of 25 on a slice
of 0.3 rad cut its shadow off at the layer's edge (up to 255 levels).

Only an ellipse that goes all the way round now grows by exactly its
spread. `figcraft_geom::full_sweep` is what `ellipse` already used to
tell a whole ellipse or ring from an arc, so the two agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: an arc's mitred stroke gets room for its corners too

Review: `outline_reach` counted every ellipse as an outline whose stroke
mitres end on the grown box, the same mistake as the spread. A thin
slice with a 20-unit mitred stroke and a spread of 10 still had its
shadow cut off (255 levels). It now asks `grows_by_exactly_its_spread`,
which picks the same shapes and leaves arcs out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:12:27 -05:00
samhyunandClaude Opus 5.5 8c93d0559f M8: dots quantize their position apart from the view's origin
Review of #1796: quantizing the mark's distance from the view's corner
took in the error of the view's origin converted to pixels, so a mark
one 1/65536 px step short of a half could still cross it. At a step of
24 and a zoom of (75.5 - 2^-17) / 24, a pan by 51 pixels moved the
first mark. The mark's position and the origin are now each quantized
before the one is taken from the other.

The origin rounds its halves down: some 4 x 10^10 pixels out, its error
is exactly half a step, and rounded up it moved the marks again. Past
2^37 pixels the step is finer than f64 and values are used as they
are, so huge ones no longer overflow to infinity and come out as a
mark at the view's corner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:22:29 +09:00
samhyunandClaude Opus 5.5 c8f34acc59 M8: dots on the 1/65536 px rounding step stay put too
Review of #1796: rounding a mark's position to 1/65536 px with `round`
sent the halves of that step away from zero, the asymmetry the PR set
out to remove, one step further down. At a step of 25.5 - 2^-17 px, a
pan by 26 pixels put the first mark a pixel off a full render. Both
roundings now go half up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:40:05 +09:00
samhyunandClaude Opus 5.5 e39e563408 M8: dots halfway between pixels stay put across pans, bands and patches
The board's dot grid puts each mark on the device pixel nearest its
page position, measured from the view's top-left. At a zoom of 1.0625 a
step of 24 is 25.5 pixels, so every second mark falls halfway between
two pixels, and a view a whole number of pixels on could put it on the
other one: `round` sends halves away from zero, so a mark left of the
view's origin went left and one right of it went right, and the origin
`(px + offset) / scale` reached the half with another rounding error.
A pan moved those marks by a pixel (up to 123 levels off).

Positions are now rounded to 1/65536 px and then half up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:21:00 +09:00
dotCooCoo 000d39b042 M4: closed vectors take corner smoothing
A vector's corner smoothing was stored, imported and exported, but its
rounded points were drawn as plain circular arcs, and the Design panel
hid the Corner smoothing control for vectors.

A vector with a closed region now draws its rounded points with the
layer's corner smoothing, on the canvas, in hit testing, in PNG, SVG and
PDF exports, and in the fill and stroke geometry of .fig exports. Each
point keeps its own radius; a point without one stays sharp. The corner
uses the profile of smoothed polygon corners, and smoothing is reduced
before the radius when a corner does not fit in half of each segment.

FigCraft's own rules for cases the help page does not describe: only a
point between two straight segments that both bound a closed region is
smoothed; points on open branches and points next to a curved segment
keep the plain arc; concave corners are smoothed like convex ones.

The Design panel shows Corner smoothing and its iOS (60%) button for
vectors with a closed region, and not for open paths.
2026-10-31 02:33:38 -07:00
BFlat 6b28ef57dc M4: Dash cap setting for Dash and Custom strokes (#1728) 2026-10-31 09:41:39 +07:00
BFlat bdb6a05976 M8: regression tests for one-sided strokes on rounded shapes and XML-forbidden characters in SVG (#1351, #1124) (#1719)
Both reports were checked by running them and are already fixed on main. Pins a rounded rectangle or frame stroking only the side asked for in every alignment, and SVG font families and ids leaving out characters XML forbids.
2026-10-31 07:41:40 +07:00
NetterSilverandClaude Haiku 5.5 8fbb693370 Honour Round and Bevel joins on outside rectangle strokes
Found while testing crates/render/src/draw.rs:190-240 (stroke_area, the Inside/Outside ring fast path)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 00:11:23 +01:00
BFlat e94350a778 Merge pull request #1630 from samhyun/perf/partial-damage-margin
M8: an edit renders where the changed layer paints, its effects' margin once
2026-10-30 15:07:13 +07:00
samhyunandClaude Opus 5.5 40f83a9a08 M8: a patch reads no backdrop past the view's edge
Background blurs and glass read the pixels drawn under them, and a full
render's backdrops end where the view does. The retained renderer differed
in two ways.

render_into drew each damage rect with a margin all round, past the edge
too, so a backdrop there read content a full render never drew: with a
background blur of 60 across the left edge, moving a square beside it
patched 10 levels off a full render, and moving one entirely off the view
89. The margin now stops at the edge.

A pan re-rendered only the strips it uncovered. At the opposite edges, which
moved into the old frame, the shifted pixels kept what their backdrop had
read past the old edge: a background blur of 10 across the view was 89
levels off after a pan of 30. Those edges are now redrawn as far as a
backdrop reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:26:36 +09:00
samhyunandClaude Opus 5.5 a3d70d4c43 M8: an edit renders where the changed layer paints, its effects' margin once
paint_bounds, the area a change to a layer can affect, was its paint bounds
(effects included) grown again by effect_margin, what its effects read from
outside. Drawing an area already takes that in, as culling does, so the
damage of an edit near effects was counted twice: moving a 120 x 120
rectangle with a shadow falling 40 rendered 122,844 pixels for 56,876 it can
change, and edits that would fit a partial render fell back to full ones.

The retained test that a group's shadow had pushed past the partial share
gets its 300 x 220 view back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:10:49 +09:00
BFlat 15479eacb8 Merge pull request #1626 from samhyun/fix/scaled-effect-reach
M8: effects reach as far under a scaled layer
2026-10-30 13:01:40 +07:00
samhyunandClaude Opus 5.5 07c936f560 M8: PR #1626 review: tiny scales and pixel-spread layers
local_reach gave up on transforms whose determinant was under 1e-12 and
kept the reach in page units, so a layer scaled to a ten millionth (a
billion units drawn 100 wide) had its shadow cut (245 levels). It now
converts any finite, non-zero scale, capping the result where the sums
would overflow.

Groups and stroked text spread their shadows as pixels, in page units:
their spread no longer also counts in local units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:12:58 +09:00
BFlat 7fdbda9f09 M8: One depth cap for a whole render, and render threads with room for it (#1605)
* M8: one depth cap for a whole render; render threads get 8 MiB stacks (#1082)

A pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels. The source now goes on from the depth of the layer it fills. A render level takes about 6.5 KB of stack (measured), about 1.7 MB at the cap: the canvas render, preview and save threads and a band pool of their own now get 8 MiB stacks instead of the default 2 MiB.

* M8: SVG and PDF exports carry the depth into pattern sources and embedded renders (#1082)
2026-10-30 10:43:49 +07:00
samhyunandClaude Opus 5.5 0df9189750 M8: effects reach as far under a scaled layer
The renderer draws drop shadows, layer blurs and texture at their size in
page units however a layer is transformed, but sized a layer from its local
box grown by the effects' reach in local units, so under a transform that
scales down the effect was cut (245 levels on the canvas, exports holding
half to 60% of the paint).

bounds_within now takes the layer's local-to-page map and turns the reach
into local units per axis. A drop shadow's spread of a filled outline is the
exception: the outline is grown before it is transformed, so that part
follows the layer's scale. Layer bounds and own_paint_bounds take, per
shadow, the larger of the two readings, so an unscaled layer comes out as
before; exports of scaled-up spread shadows, which were cut, now hold them,
and so do the corners of a turned rectangle's spread. The mitre reach is
clamped like the other reaches, so a huge spread no longer makes bounds NaN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:03:28 +09:00
samhyunandClaude Opus 5.5 81f89b314b M8: a group's effects reach past what its children paint
A group or an unclipped frame applies its layer blur, drop shadows and
texture to everything its children paint, including children reaching past
its box. The renderer sized the container's layer from the union of its
children without growing it by the container's own effects, so the blur or
shadow was cut where a child's paint ended; exports of unclipped frames
lost part of the shadow the same way.

bounds_within now grows the children's union by the node's effects reach,
and render_bounds_at grows an unclipped frame's children by its effects.
The retained partial-render test drew this very cut (61 levels in its group
child scene); with the shadow counted the damage passes the partial share
of its 300 x 220 view, so the view is widened to keep it a partial render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:51:57 +09:00
BFlat b2b9e08a46 Merge pull request #1589 from samhyun/fix/export-stacked-blur-bounds
M8: exports hold what stacked layer effects paint together
2026-10-30 07:53:10 +07:00
BFlat 7ed4e7cd61 Merge pull request #1583 from samhyun/fix/sequential-layer-blur-margin
M8: stacked layer blurs keep the margin they reach together
2026-10-30 07:50:55 +07:00
samhyunandClaude Opus 5.5 872c85bf7d M8: review on #1583: the too-large margin test asserts the capped sum
The test only checked for a large finite margin, which the old largest-effect margin (1e308) passed as well. It now asserts the two capped reaches added up, so it fails on a return to either the old maximum or an overflowing sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:13:26 +09:00
samhyunandClaude Opus 5.5 9426eff1ff M8: exports hold what stacked layer effects paint together
Layer blurs and texture apply one after another and drop shadows are cast from what they leave, but an export grew the node's box by each effect on its own. Stacked blurs came out cut at the image's edges (eight blurs of 10: alpha 10 at the edge), and a sharp shadow under a blurred layer lost the blur around it (alpha 126). Blurs now combine as one whose squared reach is the sum of theirs, texture adds on, shadows grow from that, and paint_reach follows the same rule. Reaches are clamped so adding them stays finite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:10:14 +09:00
BFlat cc3052de5e M8: cap the input of boolean operations (#1104) (#1581)
Each sweep takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted in O(n log n)), and a fold at most 4 million segments swept over its steps; past them the operation is refused with PathOpsError::TooComplex before it runs. A boolean group past the caps draws without its result, with a warning once, and the refusal is cached; vector.flatten refuses it instead of dropping it.
2026-10-30 06:03:35 +07:00
samhyunandClaude Opus 5.5 be9223a9ed M8: stacked layer blurs keep the margin they reach together
Layer blurs and texture apply one after another, so together they reach further than any one of them, and drop shadows are cast from the blurred layer. effect_margin took the largest single effect, so a layer cut at a view's or band's edge missed content that still reached in: four blurs of 25 drawn in 64-row bands differed from a render in one piece by 8 levels, and a blurred rectangle above the view cast its shadow into it 143 levels off. The margin is now the layer effects' reaches added up, plus the largest of the others; each reach counts up to 1e12 so the sum stays finite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:58:28 +09:00
bflatastic 4ec382ea86 Merge main into #1547 2026-10-30 00:32:08 +07:00
bflatastic 890116d55e Merge main into #1496 2026-10-29 23:25:44 +07:00
liwaichung-source 366b981aa7 Merge branch 'storytold:main' into main 2026-10-29 22:53:38 +08:00
Claude 2d507d959d M12: board grid settings: dots, lines, graph paper or none
The board canvas's background grid is now a preference (`board.grid`): dots
(the default), lines, graph paper (a strong line every 2-12 cells over fainter
ones, like Japanese section paper) or none, with spacing (4-512), mark size and
contrast. Preferences gets a Board grid section; right-clicking an empty board
offers Board grid settings. Zoomed out the grid still thins out (by halves, or
down to graph paper's strong lines), and dots no longer shrink to a single
pixel, so the grid stays visible at low zoom.

The renderer draws lines as well as dots, with a configurable strong-mark
rhythm; a crossing of two lines is blended once, in the stronger colour. A
hand-edited preferences file falls back to the default grid instead of losing
the other preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkCVd8n1eC6xTHebmHMDR
2026-10-29 14:44:18 +00:00
BFlat 96eed15993 M8: mask groups draw over their own area, nested layers share one memory budget (#1061) (#1535)
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.
2026-10-29 21:33:46 +07:00
samhyunandClaude Fable 5.1 3f896453b6 M8: a test for stacked background blurs drawn in bands; the eight-band wording
From the review of #1496: three background blurs stacked on each other across the band
boundaries draw the same in bands as in one piece (within anti-aliasing), so the bands'
overlap, the reach of the largest blur, is enough, as it is for the desktop's parallel bands;
pinned by a test. The docs and the `MAX_BANDS` comment no longer put the longest frame at "an
eighth of the render": a frame draws at least a band, a part of the render rather than the
whole, and the numbers measured for #1496 are cited instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 23:08:12 +09:00
samhyunandClaude Fable 5.1 6d8b0e68f0 M8: the browser draws a zoom's full render a band at a time
Once a zoom had paused, the browser drew its full render in one frame. On a heavy page that
frame took seconds, and no input was handled meanwhile: the wheel steps and drags that came
were seen only when it was over.

The renderer's `Retained::begin` hands a full render back as `Steps`, drawn `band_h` rows at a
time with the same `render_band` and `put_band` the desktop's parallel bands use (bands overlap
by what background blurs read and keep their own rows; a blur reaching past a band makes the
bands taller rather than one piece), and `finish` keeps the image as a full render. A render
that is reused or patched is done at once, as before.

The browser's `Inline` worker refines through `Renders::refine_for`, which draws at least a
band a frame, more while they fit in `STEP_MS` (24 ms), and asks for the next frame while bands
remain, so input is handled between them; a view has at most eight bands, so on a page whose
render takes seconds the longest frame is about an eighth of it. A render drawn for the very
request is done once drawn. A pan or an edit meanwhile at the same zoom keeps the render going
and is patched in once it is drawn; a pan too far to patch or an edit the patch can't take draws
it again in steps, the image just drawn showing till then (`Refined::Shown`); a zoom, another
page or file, another size or other render options drop the render for one of the new view,
and what its bands took stands as the measure the next zoom's preview and wait go by. A render
in steps counts as one render (glass keeps the shapes a later render asks for again). The
desktop, with its render thread, is unchanged.

Measured in Chrome on two synthetic pages (20 images and 60 shadowed rectangles, with and
without two background-blur panels) whose full render takes 3.6–4.0 s at a deep zoom: the
longest frame while that render runs went from 3.7–4.0 s to 0.9–1.4 s (blur) and 0.69–0.80 s
(no blur); the bands together take longer than one render (the refined image comes after
6.7–8.1 s and 5.3–5.5 s instead of 3.8–4.1 s), each band rendering its overlap again and
repeating some per-band work, shadows mostly. The zoom steps are as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 22:37:35 +09:00
BFlat e32f8f2e07 M8: large images on the canvas draw from a smaller copy (#1483) (#1518)
* 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)
2026-10-29 18:14:18 +07:00
BFlat 0971d096db M8: the progressive JPEG check guards file thumbnails too (#1517)
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.
2026-10-29 17:42:53 +07:00
BFlat 1df9190d0c M3: shadows of images with transparent pixels follow their alpha (#1289) (#1509)
* 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)
2026-10-29 15:43:08 +07:00
bflatastic 2a63418d57 #634: region fills drawn by their own helper, and the long region-paint test split by case, for cognitive_complexity 2026-10-29 12:28:47 +07:00
bflatastic 789fa33444 Merge main into #634 2026-10-29 12:25:06 +07:00
samhyunandClaude Fable 5.1 bdddf9a2ba M8: the baseline fixture of the JPEG sampling test is a well-formed file
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>
2026-10-29 11:16:17 +09:00