Three ways a stroke painted past the bounds an export and a redraw go
by, with no effects at all:
- A dash's square end stands out along the path as far as the band
does across it, so its corners reach √2 times as far from the path.
Round a curve that is past the box grown by the stroke.
- The ink brush's ragged edges reach 0.54 of the weight from the path,
not 0.5.
- A scatter brush's copies stray by the weight whatever a width profile
does, but the profile's scale was applied to the stray as well, so a
thinning profile left them out.
`outline_reach` now counts square dash ends as √2 at least, and
`Brush::reach_factor` takes the profile's scale for the shapes only and
counts the shapes' reach (`Brush::SHAPE_REACH`), which a test checks
every preset against.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
* M4: bulleted lists keep the same bullet at every level of indentation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* list_markers: the comment says bullets are the same at every level
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M4: typing 1) and a space starts a numbered list, as 1. does
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Parity: the Text area row names the list creation characters
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Parity: the list creation characters without spaces inside the code spans
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Shortcuts: Cmd/Ctrl+Shift+Alt+= / - / 0 make the interface larger, smaller and default-sized
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Interface size keys: live while editing text too, and the new size draws on the next frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* A preference set during a frame repaints this window too, so the interface size applies on the next frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Control channel: a preference changed by a hidden window's command repaints every window
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Shortcuts: Cmd/Ctrl+Alt+arrows rotate a degree at a time, 15 with Shift
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* The rotate gesture's label in the English catalog
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Rotate keys: about the selection's rotation origin, not while editing points, Dev Mode refusal on the control path too
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M3: Delete on a layer inside an instance hides it instead of being refused
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* The components structure test: Delete inside an instance hides
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* M4: a link on text is underlined by default, and Underline takes it off
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* text.link: only a new link takes the default underline; Underline reads the texts inside a target
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Parity note: in the browser Ctrl+1 is the browser's tab key
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* 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>
A .fig file (and the REST JSON) holds an ellipse's start and end angles
as 32-bit floats, and FigCraft writes them that way too. For a whole
turn that starts anywhere but zero, end - start comes out a hair short
of 2π as often as not (start 0.3: 6.283185184), which reads as an arc:
the ellipse came back as a pie slice whose centred stroke draws a line
from the centre to the edge.
On import, a sweep within the angles' precision of a turn is now a turn.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/plugin/src/manifest.rs:70 (api_from_number_or_string, the Api::Text arm)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* M8: .fig import: a routed connector takes its line style from the generated record (#1807)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* M8: .fig import: a routed connector keeps its own empty paints and dash pattern
CodeRabbit review on #1813: an explicit empty strokePaints (no stroke) or
dashPattern (solid) on the connector no longer takes the generated record's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* 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>
The SVG export blurs a spread drop shadow in a filter whose region is
the silhouette's page bounds grown by the spread's mitre and the blur.
The spread grows the outline in the node's own units, though, and the
transform then scales it: on a star scaled three times, a spread of 10
mitred out to some 97 units while the region only made room for 48, so
SVG viewers cut the shadow's points off. The spread now grows the region
as far across and down as the transform takes a local step of that
length, as the paint bounds already do.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
Found while testing crates/format/src/fig/kiwi.rs:190
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-6-Luna <noreply@openai.com>
Review of #1795: the tests checked the rounding of grain cells but not
the move texture now adds in whole pixels. Rounded cells with the old
`f32` move still passed them. A pan across a 2,400-pixel-wide striped
layer catches it: past 1,024 pixels, the old move came out a pixel off
for some pixels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Found while testing crates/format/src/fig/kiwi.rs:615
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-6-Luna <noreply@openai.com>
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>
Noise and texture lay their grain cells out in the layer's own
coordinates, so a patch, a band or a pan should draw the grains a full
render draws. At a zoom of 1.7, a 1-unit cell is 1.7 pixels across and
every tenth edge falls on a row of pixel centres; a render whose origin
is a whole number of pixels off reached that edge with another rounding
error and moved the row into the next cell. An edit over a textured
layer patched up to 197 levels off a full render, and a noisy one 224.
Cell positions are now rounded to 1/2^20 of a cell before they're cut
into cells, and texture adds its scatter to the pixel index in whole
pixels instead of flooring it in f32 together with the index.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up on #1773: the carry-forward now covers red, green and blue in an sRGB mix (converted first in another space, so zero there, per CSS Color 5 section 4.1 on analogous components) as well as the alpha in every mix, and a component missing in both colours stays missing in the result, so an enclosing mix carries it from its other colour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found while testing crates/svg import (no existing issue), each with a test that fails before and passes after. An <image preserveAspectRatio="none"> took ImageScaleMode::Fill, which keeps the picture aspect and crops; a box of a different aspect now takes a whole-picture Crop, which the renderer draws as a stretch (SVG 2 section 8.6). A stroke-dashoffset equal to half the first dash was skipped on every shape because an open line starts half a dash in by itself; a closed outline runs its pattern from its start, so the exemption now applies to open outlines only (SVG 2 section 13.5.6).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #1770: the typed colour keeps the page opacity only when the text carries no alpha (not 4 or 8 hex digits); FF0000FF on a translucent page now sets 100%. Test extended.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #1766: a backward wall-clock step between the push and the test read made the ordering assertion flaky.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>