* CI: line tables only for workspace crates, no debug info for dependencies
* CI: one integration-test binary per crate
Each crate's tests/*.rs files become modules of tests/it/main.rs. Files that change
process-wide state (Color Settings, the font database, installed plug-ins, the working
directory), the fuzz sweeps and the test targets the Affinity corpus workflow names stay
their own binaries.
* Docs: name the integration tests' new paths
* CI: xtask test with nextest and doctests, one wasm check, clippy --keep-going
cargo xtask ci runs the tests with cargo nextest (cargo test when it is not installed),
builds them with --lib --bins --tests, runs the doctests as their own step, and checks
the wasm crates in one cargo check --keep-going call.
Where two passes of a path (an open line's way there and back) cross another
path, the sweep can cut them a rounding error apart and join the two cuts
with a stub a little over its tolerance. Upstream's twin-piece joining
already merges the ends of the two passes, but not such stubs, so dense
hatches at exact binary steps (lines every 0.5 pt across a 45° line) still
came back with a speck of a piece per crossing. The planar map now contracts
such stubs into one vertex before the edges are built (within 4x eps up and
down the sweep's frame and 1/16 of the 0.01 pt precision along it), bounded
by each cluster's extent so a run of tiny, nearly level segments (a finely
flattened or traced path) is not collapsed transitively.
Ported from the fork's d5985b14 and 616a5c4a with their tests:
a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing (fails
without the fix), a_run_of_tiny_level_segments_is_not_contracted_as_stubs
(fails without the extent bound) and
a_line_crossed_where_its_two_passes_part_is_cut_once (already passing on
upstream, kept as a regression test).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCpjL3yUhaH3jRp7ksufh
The Pathfinder panel menu's Trap… (object.pathfinder.trap) and Effect › Pathfinder › Trap… (pathfinder.trap, live on groups) share one geometry (vectorcraft_pathops::trap_band, vectorcraft_effects::trap): Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps, plus the effect's Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object. Behaviour from https://helpx.adobe.com/illustrator/using/trapping.html and https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html.
The Simplify help page says a path can be simplified whole or "a specific
path region with the Direct Selection tool"
(https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html).
Simplify refitted every selected path whole, whatever part of it was
direct-selected.
object.path.simplify now looks at each path's direct selection: the
segments picked as segments, or else every segment with both anchors
selected. Each run of consecutive selected segments is refitted on its own
(vectorcraft_pathops::simplify_part) between its two end anchors, which stay
where they are with their handles on the side away from the run; every
other anchor and segment stays exactly as it was. A closed path's run may
cross its start point (the path then starts at the run's end anchor),
separate runs on one path simplify independently in one undo step, and the
runs stay direct-selected (as segments when segments were picked). A path
with only lone anchors selected still simplifies whole, as before, and
wholePaths: true simplifies partly selected paths whole. The slider, its
preview, Auto-Simplify and the Simplify dialog all run the command, so
they act on the part too. simplify_with now shares its run fitting with
the part refit (same results).
Verification: crates/engine/src/tests_simplify_part.rs (unselected anchors
and segments equal bit for bit, run end anchors fixed, the run within the
tolerance, two runs equal to each simplified alone in one undo step, a
closed run across the start point, picked segments, wholePaths, slider
preview and Auto-Simplify, a run that can't be simpler records no step)
and simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path
(the slider's preview, Enter and the dialog's OK). Live: the release build's
slider at level 20 on a 91-anchor wave with anchors 30–60 selected refits
only that stretch.
The Simplify help page says the slider starts at an auto-simplified value
(https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html).
The slider opened at a fixed level 50 (1 pt), whatever the size of the
selection, so the first preview was the same tolerance for a 30 pt icon as
for a 1000 pt drawing.
The level Auto-Simplify picks for the selection is now one engine function,
vectorcraft_engine::auto_simplify_level (the existing 1/400-of-the-longer-
side rule in vectorcraft_pathops), which object.path.simplify {auto: true}
and the slider's opening fields both use. Object › Path › Simplify (and
ui.menuDialog, the Properties and contextual task bar buttons, which go
through the same menu dialog) opens the slider there, and the canvas
previews that result; Auto-Simplify still moves the slider back to it.
The retained Simplify dialog path is unchanged.
Verification: simplify_slider_opens_at_the_auto_simplify_level_of_the_selection
opens the slider on 1140, 285 and 28.5 pt waves (levels 35, 55, 89) and
checks the preview is exactly object.path.simplify {auto: true}'s result;
without the change it fails with level 50 != 35, and
simplify_opens_a_slider_whose_drag_previews_fewer_or_more_points fails with
{"level": 50} != {"level": 55}. cargo xtask ci: all 10 steps passed.
The planar map found each edge's source input by scanning every input
segment, and the face around each hole or merge point by scanning every
face, so it was quadratic in the segments; the Shape Builder therefore
gave up past 4096 segments (traced or type outlines) and offered whole
shapes. Index outline segments and faces by bounds on both axes
(RectIndex), walk an edge's segments lazily when finding its leaving
angle, and raise SHAPE_BUILDER_MAX_SEGMENTS to 65,536. Divide with open
paths shares the cap; its cap test now follows the constant.
Tests: a 20,000-segment blob cut by two lines, a 300-line hatch, the
index against a brute-force scan, and the existing cap test at the new
cap. Progress docs updated (G11, parity evidence, roadmap).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCpjL3yUhaH3jRp7ksufh
Overlapping faces take the highest of each CMYK channel from every covering shape and drop strokes, matching Illustrator's Hard Mix effect.
Co-authored-by: Cursor <cursoragent@cursor.com>
Object > Live Paint > Gap Options was a stub, and paint leaked through
any gap between the paths of a Live Paint group. The reference app's
Gap Options stop paint at small gaps, close them with unpainted paths
and preview them on the canvas.
livePaint.gapOptions (Gap Options) sets a group's options: detect (Gap
Detection), size (small 3 pt, medium 6 pt, large 12 pt, or a custom
size over 0 up to 72 pt), color (the Gap Preview Color, #rrggbb or a
layer color's name) and closeGaps, which adds an unpainted path across
each gap to the group's paths. A gap runs from a loose end of a path
to the nearest point of any path within the size, unless the paths
already join the two close by. With detection on, the faces are made
again as if a line closed each gap, and each face or edge keeps the
look of the old one it lies in or on. The options are stored on the
group, so they are saved, copied and undone with it, and Expand drops
them. Merge keeps the bottom group's options when its Gap Detection is
on, or else turns Gap Detection on with Small when another merged
group has it on. When Gap Detection is off in every merged group,
Merge keeps it off. livePaint.info lists the options and the gaps up
to the size wide that no path closes.
The menu item opens a Gap Options dialog. With Preview on, the options
apply live and the gaps show on the canvas in the preview color, and
OK keeps them as one undo step. vectorcraft-pathops gains find_gaps
and live_paint_with_gaps.
Docs: docs/mcp.md, docs/target-app-parity.md.
i18n: the 19 complete catalogs have rows for the dialog's labels, and
bg's Gap Options menu label now names gaps instead of line spacing.
The Shape Builder merges faces by uniting their contours, and the edges
two faces share cancel out. The sweep can give two faces copies of a
shared edge whose ends lie a few times its tolerance apart, with the
difference left as pieces shorter than the tolerance. The union could
then keep the space between the copies: a hairline that joined a hole
as a spur, or a seam that cut a petal tip off the ring's outer outline
as an outline of its own. On Apple Silicon, turning one petal of the
rosette test by -180 degrees instead of 180 leaves a spur.
A Shape Builder action unites the picked faces first, and then what is
left of each shape. When two copies of a shared edge differ in the
first of these unions that unites the faces' contours, each point of
the faces' contours that lies within 16 times the sweep's tolerance of
a point kept before it now moves onto that point before that union and
each later one, with the handles next to it. A piece left within that
distance of one point is dropped. The planar map joins the ends of
copies of an edge within the same distance. Copies that share their
ends leave at most a hairline contour, which the merge drops as a
sliver. When the copies in that union all match exactly, the unions
take the contours unchanged. Only the Shape Builder's merges and
deletions unite faces this way, so Pathfinder output does not change.
every_pathfinder_operation_is_pinned built its stars with shapes::star,
which calls the platform's cos and sin. At the star angle
2.204350845268838, cos on macOS x86_64 (measured under Rosetta on
macOS 27.0.1) returns 0xbfe2f1c59f37cbf4, where the correctly rounded
value, which macOS aarch64 returns, is 0xbfe2f1c59f37cbf5. In the set
of five shapes with seed 7, this moves one star vertex by one ulp, and
the set's Unite hashes to 60181664961792917, where PINNED has
9388160212451732406. Under Rosetta, 21 of the 40 hashes differ.
The test now builds each star from a table of the correctly rounded
cosine and sine of the star's ten angles, written as f64 bit patterns,
with the arithmetic of shapes::star. PINNED is unchanged. With these
inputs, all 40 hashes equal PINNED on aarch64 and under Rosetta.
Object > Path > Split Into Grid took a number of rows and columns and one gutter, and split each
selected path on its own. The dialog now has the documented options, the rows beside the columns:
number, height or width, gutter and total, with Add Guides and Preview. Several selected paths
become one grid over them all, painted as the topmost one, as documented.
object.path.splitIntoGrid takes rowGutter and columnGutter (gutter still sets both), totalHeight
and totalWidth, height and width (the size of each row and column, which sets the total) and
addGuides, which adds ruler guides along the row and column edges on the grid's layer.
Behaviour reference:
https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html
Since #1035 moved linesweeper to 0.5, the sweep can cut the paths
through one crossing at points more than its tolerance (eps) apart.
The planar map merged a point only into a vertex within eps of it, so
the two passes of an open path could end on two different vertices,
and it kept them as two edges with different ends. Past the crossing,
shape_builder then returned a line's loose end as one piece that runs
out to the line's end and back, and an extra piece of the line it
crosses, about 1.4e-6 long. In live_paint, the face around the loose
end ran out along it and back, and FaceMerger::merge of the faces
returned a second subpath along it. pieces_cut_out_of_their_inputs
fails on main for such an input.
The map now joins the ends of pieces whose middles and ends lie within
16 eps of each other. The two passes then share their ends and become
one edge. The new test is the failing input proptest saved for the
property. Lines that slope close to -1/32 in the document are nearly
level in the sweep's frame, and the change does not cover all of them.
At 50,000 paths unless noted, against main:
- Pathfinder Outline (300 paths): 145 → 7.5 ms. Each segment was tested against every junction
of the arrangement, and each chain against every chain kept so far. Now a segment tests only
the junctions inside its control box (junctions are sorted by x; a point within reach of a
curve is within reach of that box), with the same nearest-point test. Coincident chains are
found through a grid on their midpoints, taking the first match in the same order a scan does.
- export SVG: 267 → 176 ms. Path data is written straight into one string (`push_num`, the
same digits and trimming as `fmt_num`) instead of a string per number, point and segment.
- open .vectorcraft: 239 → 195 ms. The JSON was parsed once only to find where it ends:
- A file with no NUL has no data after it.
- Otherwise the marker (which holds the first NUL, as JSON can't) is taken when it directly
follows the JSON's last byte, as the writer puts it.
- Anything else takes the old parse.
`perf --deep` prints output hashes of an SVG export and a save → open → save round trip; main
and this branch print the same ones. pathfinder_pinned (Outline included) passes, and so do the
pathops, format and svg suites (345 tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
Trim, Merge and Crop walk the arrangement once per input shape, and every walk tested each edge
side with a coverage mask built from a winding vector with one slot per input. On 300 paths,
against main:
- Trim: 362 → 31 ms
- Merge: 366 → 31 ms
- Crop: 303 → 28 ms
- Unite, Intersect, Exclude, Minus Front and Minus Back: unchanged or a little faster
- Multi, the arrangement's winding, keeps only the non-zero windings as sorted (input, winding)
pairs, added in place. A face is covered by a few inputs, so a test reads a few entries
instead of one per input. The winding arithmetic is unchanged.
- Arrangement::covered lists the inputs that cover a face (the true entries of its coverage
mask). Trim, Merge, Crop, Minus Front, Minus Back, Intersect and Exclude test that list; each
test's old mask form is in a comment beside it. mask() still builds the same mask for the rest.
pathfinder_pinned hashes the output of all ten operations on 2, 5, 12 and 30 overlapping
ellipses, rectangles and stars (even-odd and non-zero, shared keys for Merge). The 40 hashes were
computed with main's dense windings, and the new code gives every one of them. A wrong Crop test
fails it. The pathops suite (103 tests) passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
After linesweeper 0.5 (#1028 on main), uniting the faces of a grid of
lines could split the merged strip again even with their corners shared
exactly: a sweep over seven or more faces moved shared vertices by about
1e-6 (within its eps) and the edges between faces no longer cancelled.
Faces cut by open paths now merge like closed shapes' faces already do
(FaceMerger::exact): in the planar map itself. build() is split into
map() (the graph and its faces) and build(); Map::merge traces the
boundary of the picked faces along the map's own edges, dropping the
edges between two picked faces and the loose ends inside them, so no
sweep runs on the result. FaceMerger finds each face again by a point
inside it, checks the area, and keeps the boolean union as a fallback.
Tests: the regression test merges through FaceMerger; a grid test
covers a ring with its hole, cells touching at a corner, a loose end,
and that the map path (not the fallback) produced the result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The planar map merged sweep endpoints within eps into one vertex id but
kept each segment's own copy of the coordinates, so faces meeting at a
vertex could disagree on that corner by ~1e-13. Uniting them then failed
to cancel their shared edge, and a merged strip came back as several
touching subpaths whose stroke drew the inner edges as leftover lines.
Pin every edge's ends to its vertex's canonical position, so faces share
their corners bit for bit. Regression test from the reporter's grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep under Offset Path (linesweeper 0.4) never finished on some
miter offsets: on the reported glyph and on a synthetic outline it kept
allocating until the app ran out of memory. linesweeper 0.5 fixes that
infinite loop ("Fixed an infinite loop", 0.5.0 changelog), and builds
without changes.
0.5 can hand back a curve piece that doesn't follow the input curve it
was cut from. In the Shape Builder rosette test, a contour runs out along
one petal and back by such a piece, which became a 59 x 4 pt hole in the
merged shape. Tidy::repair rebuilds a piece that strays from the one input
curve through both its ends as that curve's exact sub-curve (input curves
are indexed by their bounds' left edge, so it stays cheap). The there-and-
back then encloses nothing and is dropped as a sliver. Pieces on their
curve, and pieces whose ends two input curves pass through (a real lens),
are left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging regions united the faces' outlines with a boolean union. Each
face had been refit on its own, so two faces' shared edge could come out
a hair apart: the union then kept the edge between them, or a spur of
it, and a stroked merged shape (unfilled art) showed a stray line inside.
Subtracting the merged shape from the others left slivers along it too.
pathops::FaceMerger merges faces from the planar arrangement itself,
made once per merge: their contours share their edges exactly, so the
edges between merged faces vanish. The Shape Builder builds the merged
shape that way, and what is left of each shape from its faces not merged
(rather than subtracting the merged shape from it). Selections with open
paths keep the old union.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alt already deleted regions and pieces of open lines, but erase mode had
no edges of closed shapes, no distinct pointer, highlighted regions in the
merge-mode grey, only noticed Alt on a mouse move, and an erase drag could
flip mode at release. Like the reference app's erase mode:
- pathops::shape_builder(shapes, edges) returns a BuilderArrangement; with
`edges` it also cuts the outlines of closed (and filled open) paths
wherever other paths meet them. pathops::cut_out removes such a piece
from a path: a closed subpath opens there, an open one splits or
shortens, a piece all the way round takes its subpath, and a filled open
path's invisible closing edge never comes back as a visible segment.
Specks where paths only touch cut nothing; a zero-length input is no
longer the source of edges running through it. encloses_area is shared
with the tool's closing of filled open paths.
- BuilderMap (with_edges) hit-tests edges like line pieces (nearest wins);
an erase gesture that crosses an edge into a region deletes the region,
not the edge (erased_edges).
- shapeBuilder.merge {erase}: deletes the edges touched within tolerance
(from every shape along them), after the regions; returns `edges`.
shapeBuilder.regions lists `edges` (bounds, source). One undo step.
- Shape Builder tool: Alt held switches to erase mode and back on release:
red mesh/edge highlights of what will be deleted, a minus pointer
(Cursor::ShapeBuilder / ShapeBuilderErase, a plus in merge mode); a drag
keeps the mode it started in. The canvas re-hovers the tool when a
modifier changes, so the highlight follows Alt without a mouse move.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Shape Builder only used the filled areas of the selected paths, so a
stroke-only line or open path across a shape did nothing. Like the
reference app's Shape Builder, open paths now act as cutting edges:
- pathops::shape_builder: with an open path in the selection, the
regions are the Live Paint planar map's faces (every path an edge), so a
line across a shape splits it and lines can enclose a region of their
own; it also returns each open path cut where other paths meet it. Closed
shapes alone, more than SHAPE_BUILDER_MAX_SEGMENTS segments, or a map
that can't be built fall back to the filled-area arrangement.
- Filled open paths count as closed (an invisible closing edge) where that
encloses an area; unfilled open paths and straight lines stay edges.
- shapeBuilder.merge: merging or deleting regions on both sides of a line
piece removes that piece (the line is split, its other pieces kept); an
area only lines enclose takes the current fill and the front-most
object's look. Alt (erase) within `tolerance` of a line piece deletes it.
Returns `lines` (pieces deleted).
- shapeBuilder.regions also lists the line pieces.
- The tool shares BuilderMap/Touched with the command, highlights the line
piece under the pointer while Alt is held and passes its tolerance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Parity: Object › Path › Remove Anchor Points now runs the new command (`path.removeAnchors`,
the menu's feature name) instead of `path.deleteAnchors`, which deleted the anchors with their
segments and opened closed paths; that stays the Delete key's command ("Delete Anchor Points",
off the menu). No second "Smart" item.
- Geometry: the refit moves to `vectorcraft_pathops::remove_anchor` on the existing least-squares
fitter (fixed end tangents, a golden-section search for where the curve passes the removed
point) instead of a ~1,000-candidate grid search in geom (about 4 ms per anchor, now well under
0.1 ms). Two straight sides stay one straight side (a rectangle loses a corner and becomes a
triangle), as Illustrator does; non-finite input leaves the handles alone.
- The Delete Anchor Point tool (`path.removeAnchor`) uses the same function, replacing its
heuristic handle stretch; the explicit id/anchor and `fit` params of the new command are dropped
as duplicates of it. The command is enabled by direct-selected anchors (`has_anchors`), not a
UI override.
- The Control bar shows an icon button (the Delete Anchor Point icon, tooltip "Remove Anchor
Points") instead of a 204 pt text button; the labels reuse the translated "Remove Anchor
Points"; zh-hant gains "Delete Anchor Points". The Pen test that pinned Alt-click as plain Pen
(Illustrator's Alt over an anchor is Convert Anchor Point) is dropped.
- Tests for the refit, closed paths, straight sides and bad input; docs/mcp.md and ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Live Paint Bucket now fills areas enclosed by any paths. Crossing
lines enclose faces it can fill, a line across a shape splits that shape's
face in two, and an area enclosed by paths becomes a face even when nothing
fills it (it starts unfilled). Edges are the paths cut wherever another path
meets them, keeping their stroke, so Shift-click paints each piece. Closed
shapes give the same faces as before.
Root cause: Live Paint built its faces from the Pathfinder regions, which
divide the filled areas of closed shapes. An open path counted only as the
area it would fill if closed, which for a straight line is nothing, so lines
divided nothing and the bucket found no face to fill. A new planar map in
vectorcraft-pathops (live_paint) sweeps every path as an edge: an open path
goes in traced there and back, with crossing counts that never cancel; faces
are walked on the resulting graph (holes included), and their fills and the
edges' sources are found geometrically. The sweep runs in an exactly sheared
frame, because it mishandles a horizontal line traced there and back.
Make, Merge, the bucket and Live Paint Selection all use it;
interior_point moves to vectorcraft-pathops with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
regions() made one linesweeper contour pass per distinct coverage mask,
and each pass walks the whole arrangement, so finding the regions cost
masks x segments. Two crossing blends make thousands of both: the
Ribbons example's EPS export never finished, and Flatten Transparency,
the Flattener Preview, Divide, Shape Builder and Live Paint slowed down
the same way on dense art.
The masks now go into batches whose faces share no vertex (a greedy
colouring of the masks meeting at each vertex), one contour pass per
batch. A face's contours are walked as in its own mask's pass, since
no other face of the batch touches it; each contour's mask is the one
of its batch at its first point (a vertex of the arrangement), and
contours group under the nearest enclosing contour of the same mask,
as grouped() does in that mask's pass. The regions come out identical,
in the same order.
Fixes#173.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
Shipped code no longer unwraps, expects or panics: every one of the 124
unwrap()/expect()/panic!/unreachable! sites outside tests now handles the
failure (Result/Option, ?, let-else, defaults) and reports errors to the user.
- Lints: workspace clippy denies unwrap_used, expect_used, panic, todo,
unimplemented and unreachable; clippy.toml allows them in tests, and
integration tests, examples and testkit opt out explicitly.
- Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls
the document back and returns EngineError::Internal; a panicking tool is
reset; the MCP server answers with an internal error and keeps serving; the
UI frame and each control request are guarded.
- APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp
return Result (JPEG/WebP errors used to write empty files),
FontDb::face returns Option, Region::top returns Option.
- Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG
and PDF through import → render → export (30k cases locally, no panics).
- Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section,
README pointer and a PR template checklist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep compares a curve with another segment over a y-span shorter than
its tolerance by bounding boxes only, so an edge rising by less than that
tolerance (e.g. a rectangle edge cut through a circle's cap, whose computed
intersection end sits ~1e-12 off the horizontal) was mis-ordered and the
result lost a corner: xor/difference/area disagreed by up to 10%. Every
sweep input now snaps on-curve y values within the tolerance to one level
(handles follow and are levelled with their anchor), so such edges become
exact horizontals, which the sweep handles exactly.
- tests/regressions.rs: the six minimal failures found by the properties,
the root cause in isolation, and the self-crossing "star" polygon.
- The star-polygon strategy rejected no angular step of half a turn or
more, so it sometimes drew self-crossing polygons (the shoelace failure).
- Property suites run from a fixed seed (PROPTEST_CASES / PROPTEST_RNG_SEED
still override), so CI cannot fail at random.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outline Stroke (and the stroke part of Expand) now builds each stroke from the
shared canvas geometry: dash shapes, arrowheads, width profiles and inside/outside
alignment, united into one clean path (pathops::stroke_region). Every stroke item
is outlined in paint order (runs of fills stay on copies of the path), stroke
opacity, blend mode and effects move to the new fill, and brushed strokes become
their brush art. The live Effect > Path > Outline Stroke outlines the stroke it
sits on (on a fill or the object: the top stroke) with that stroke's cap, join,
dashes, alignment, profile, arrowheads and weight: GeomContext now carries the
stroke and the fill rule, built per item by the renderer, export baking and
Expand Appearance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>