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>