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>