Fix Bentley-Ottmann polygon walk at shared vertices - #382
Conversation
When building result polygons, the next segment at a vertex was only searched in one direction around the snap-square event ring. Offset stroke outlines (e.g. dense adjacent cells) could leave no match and panic. Search both directions, fall back to another left endpoint in the result, and close the contour to the other endpoint instead of panicking when no neighbor exists. Add regression tests for merged grid stroke/settle. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thank you for your contribution, but I can't get your test to panic like it says it should. In fact, we go from 500 path commands to 5 (MoveTo + 3 LineTo + Close) which is exactly right. Also when drawing it looks correct. The panic is actually there for a reason, it means something else went wrong in another part of the code. I still wonder if you could reproduce the original bug you encountered? |
Imports upstream PR tdewolff#382. The previous one-direction-only search at shared vertices panicked on dense geometry (e.g. stroked outlines clipped against rects whose edges align). Searches both directions around the snap-square event ring with a fallback to another left-endpoint at the vertex.
…the panic The previous tests used a manually-crafted exact unit-square grid, which does not trigger the bentleyOttmann polygon-walk panic on the pre-fix code because all vertices land exactly on the snap-rounding grid. The real reproduction requires floating-point imprecision: actual Voronoi cell boundaries (computed in github.com/aldernero/gaul) accumulate tiny errors (e.g. 1.4e-17 instead of 0.0) that create near-coincident vertices. When these vertices are in the stroke outline of 100 merged cells and settled with the Positive fill rule, the one-directional event-ring search fails to find the next result segment and panics: next node for result polygon is nil, probably buggy intersection code Add testdata/voronoi_stroke_pre_settle.gob: the gob-encoded pre-Settle stroke outline generated from that Voronoi geometry. TestSettleVoronoiDenseGridStrokeOutline loads it and calls Settle(Positive); it panics on the pre-fix code and passes with the fix applied. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
You are correct — the original tests I added used a manually-crafted exact unit-square grid, which does not reproduce the panic. I've pushed a fix. Why the original tests didn't panicThe bug requires floating-point imprecision in the vertex coordinates. With a manually-crafted grid all coordinates land exactly on the snap-rounding grid ( The actual reproduction comes from the Voronoi algorithm: even though the sites are on a regular grid (so the cells should be perfect axis-aligned squares), the floating-point arithmetic accumulates tiny errors — e.g. a vertex that should be exactly What's in the updated PRI replaced the four synthetic tests (which all passed on the pre-fix code) with:
The fixture was generated by: // sites on a 10×10 regular grid, VoronoiWithRect gives near-exact square cells
merged := paths.Merge()
canvas.FastStroke = true
preSettle := merged.Stroke(0.2, canvas.ButtCap, canvas.MiterJoin, 0.05)
// encode preSettle to testdata/voronoi_stroke_pre_settle.gobLet me know if you'd prefer a different approach for the fixture (e.g. inlining a minimal failing path as a string constant, or referencing the gaul test as the external reproducer). |
Imports upstream PR tdewolff#382. The previous one-direction-only search at shared vertices panicked on dense geometry (e.g. stroked outlines clipped against rects whose edges align). Searches both directions around the snap-square event ring with a fallback to another left-endpoint at the vertex.
|
Thank you for the failing test, it fails here as well. I will try and create a minimal reproducible test. The fix you propose however is fixing the symptoms of a deeper bug and may not work in the general case or cause other panics. Hopefully I can make some time to find a proper fix, I'll keep you updated. |
Imports upstream PR tdewolff#382. The previous one-direction-only search at shared vertices panicked on dense geometry (e.g. stroked outlines clipped against rects whose edges align). Searches both directions around the snap-square event ring with a fallback to another left-endpoint at the vertex.
Imports upstream PR tdewolff#382. The previous one-direction-only search at shared vertices panicked on dense geometry (e.g. stroked outlines clipped against rects whose edges align). Searches both directions around the snap-square event ring with a fallback to another left-endpoint at the vertex. Upstream-Status: Pending
Imports upstream PR tdewolff#382. The previous one-direction-only search at shared vertices panicked on dense geometry (e.g. stroked outlines clipped against rects whose edges align). Searches both directions around the snap-square event ring with a fallback to another left-endpoint at the vertex. Upstream-Status: Pending
Backport of tdewolff#382, unchanged. Not our work and not ours to propose: the patch is already open upstream. Walking the result polygons searched only one direction around the snap-square event ring, and panicked with "next node for result polygon is nil, probably buggy intersection code" when it found no neighbour. The backport searches both directions, falls back to another left endpoint at the vertex, and closes a partial contour to the other endpoint rather than panicking. We carry it because Stroke followed by Settle(Positive) on dense geometry hits the panic, which takes the whole render down. No test here on purpose. The upstream PR carries one, built on captured Voronoi geometry as a gob fixture; duplicating that fixture would add a binary blob to this fork for coverage that already exists upstream. Synthetic geometry does not reproduce it — stroked grids, radial stars, overlapping rectangle lattices, concentric polygons and dense pseudo-random strokes across several seeds were all tried against the unpatched code and none panicked. Worth revisiting rather than keeping indefinitely: upstream has said the fix may be treating a symptom of a deeper problem in the intersection code, so the accepted version may not look like this. Drop this commit when tdewolff#382 lands or is superseded. Upstream-Status: Backport [tdewolff#382, still open]
Two fields on FontFace and a hook in Text.renderLineTo: when a face carries a stroke paint and a positive width, the glyph RenderPath is issued with Style.Stroke/StrokeWidth alongside the fill. The real usage is stroke-only outlined text with no fill, coming from CSS: SVG <text> styled with fill:none; stroke:... (as presentation attributes or CSS proper). The consumer clears face.Fill and sets face.Stroke/StrokeWidth from the graphic state, so only the glyph outline is drawn. Fill+stroke text is supported by the same hook, but hollow outline text is what motivates it. Known limitation: the hook lives on the text-as-paths route (Text.RenderTo), so the stroke renders in rasterized output but is dropped by renderers with native text output — the PDF renderer's RenderText only sets Tr 2 for FauxBold, and the SVG renderer writes only the fill attribute. Propagating it natively (PDF Tr 1/Tr 2 + stroke state, SVG stroke/stroke-width attributes) is the missing half of this feature. Upstream review (tdewolff) suggests the FontStroke decorator instead, with native propagation to renderers that support it. Analysis of the two designs: What FontStroke currently implements: Decorate fills path.Offset(width) — the glyph silhouette grown by width — and the glyph fill is painted on top (decorations render before the fill). This reads as an outer outline of width w, but ONLY for fully opaque fills, because the effect depends on the fill hiding the blob's interior. It breaks whenever it does not: - fill:none — the motivating case: nothing covers the interior, so the result is a solid fatter glyph, not a hollow outline. - semi-transparent fill (or gradient/pattern with alpha): the interior renders as fill blended over the stroke color instead of fill over the page — 50% red over a black stroke gives dark red glyphs. Every paint-behind-the-fill scheme shares this flaw, including the classic centered-2w-behind-fill trick. What CSS needs: SVG stroke and -webkit-text-stroke are CENTERED on the outline (w/2 in, w/2 out), painted OVER the fill (default paint-order: fill stroke); stroke-alignment (inner/outer) was drafted for SVG 2 but dropped and never shipped. Centered maps 1:1 to PDF text render modes (Tr 1 stroke-only, Tr 2 fill-then-stroke) — the outline-behind-fill geometry has no Tr equivalent. The Decorate interface draws before the fill and cannot influence the glyph's own RenderPath, so neither stroke-only nor stroke-over-fill can be expressed as a FontDecorator without changing the decoration interface. This hook strokes in the same RenderPath as the fill, which is exactly the CSS semantics. If FontStroke is to be the mechanism (the nicer design, per upstream), the fix is to make the geometry explicit with a stroke position instead of relying on paint order: - outer (compat default): the ring Offset(w).Sub(text). Never paints under the interior, so it is correct for opaque, transparent, and absent fills alike, and preserves today's rendered output for the opaque case. Costs boolean ops on dense glyph outlines (Bentley-Ottmann robustness, see tdewolff#382). - center (CSS semantics): plain Style.Stroke on the glyph path; no boolean ops; the one position PDF can propagate natively via Tr. Correct standalone; under a fill only the outer half shows until decorations can draw after the fill. - inner: text.Sub(Offset(-w)); only meaningful for fill:none or a post-fill decoration phase, since the fill covers it entirely. Conclusion: ring geometry is the only implementation that composes correctly under transparency; position (outer default, center for CSS) is the natural parameterization; and a post-fill decoration phase remains necessary for spec-correct stroke-over-fill on filled text. Until FontStroke gains that, these per-face fields carry the CSS use case. Upstream-Status: Pending [alternative: fix FontStroke as above] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fields on FontFace and a hook in Text.renderLineTo: when a face carries a stroke paint and a positive width, the glyph RenderPath is issued with Style.Stroke/StrokeWidth alongside the fill. The real usage is stroke-only outlined text with no fill, coming from CSS: SVG <text> styled with fill:none; stroke:... (as presentation attributes or CSS proper). The consumer clears face.Fill and sets face.Stroke/StrokeWidth from the graphic state, so only the glyph outline is drawn. Fill+stroke text is supported by the same hook, but hollow outline text is what motivates it. Known limitation: the hook lives on the text-as-paths route (Text.RenderTo), so the stroke renders in rasterized output but is dropped by renderers with native text output — the PDF renderer's RenderText only sets Tr 2 for FauxBold, and the SVG renderer writes only the fill attribute. Propagating it natively (PDF Tr 1/Tr 2 + stroke state, SVG stroke/stroke-width attributes) is the missing half of this feature. Upstream review (tdewolff) suggests the FontStroke decorator instead, with native propagation to renderers that support it. Analysis of the two designs: What FontStroke currently implements: Decorate fills path.Offset(width) — the glyph silhouette grown by width — and the glyph fill is painted on top (decorations render before the fill). This reads as an outer outline of width w, but ONLY for fully opaque fills, because the effect depends on the fill hiding the blob's interior. It breaks whenever it does not: - fill:none — the motivating case: nothing covers the interior, so the result is a solid fatter glyph, not a hollow outline. - semi-transparent fill (or gradient/pattern with alpha): the interior renders as fill blended over the stroke color instead of fill over the page — 50% red over a black stroke gives dark red glyphs. Every paint-behind-the-fill scheme shares this flaw, including the classic centered-2w-behind-fill trick. What CSS needs: SVG stroke and -webkit-text-stroke are CENTERED on the outline (w/2 in, w/2 out), painted OVER the fill (default paint-order: fill stroke); stroke-alignment (inner/outer) was drafted for SVG 2 but dropped and never shipped. Centered maps 1:1 to PDF text render modes (Tr 1 stroke-only, Tr 2 fill-then-stroke) — the outline-behind-fill geometry has no Tr equivalent. The Decorate interface draws before the fill and cannot influence the glyph's own RenderPath, so neither stroke-only nor stroke-over-fill can be expressed as a FontDecorator without changing the decoration interface. This hook strokes in the same RenderPath as the fill, which is exactly the CSS semantics. Note SVG itself has no knockout: fill-opacity/stroke-opacity blend per paint, so a translucent stroke showing the fill through it is spec-correct there — what must never show is paint the author did not ask for, like the decorator's hidden blob. How vector editors avoid the fill/stroke blend problem, for reference — two strategies, both instructive here: - Knockout transparency groups (Illustrator; native to PDF, 32000-1 §11.4.5): fill and stroke are composited within an isolated group where later paint REPLACES earlier paint in the overlap, and the finished composite blends with the page once. A raster engine emulates this with an offscreen buffer, drawing the stroke with Porter-Duff source instead of over. The opacity-group capability interfaces added elsewhere in this fork are the natural hook for such an emulation. - Disjoint geometry (Figma stroke align; Photoshop stroke effect): the stroke is computed as its own filled region via offset/boolean geometry, so an outer stroke and the fill simply never overlap and there is nothing to blend wrongly. If FontStroke is to be the mechanism (the nicer design, per upstream), the fix is to make the geometry explicit with a stroke position instead of relying on paint order: - outer (compat default): the ring Offset(w).Sub(text). Never paints under the interior, so it is correct for opaque, transparent, and absent fills alike, and preserves today's rendered output for the opaque case. This is the Figma strategy. Costs boolean ops on dense glyph outlines (Bentley-Ottmann robustness, see tdewolff#382). - center (CSS semantics): plain Style.Stroke on the glyph path; no boolean ops; the one position PDF can propagate natively via Tr. Correct standalone; under a fill only the outer half shows until decorations can draw after the fill. - inner: text.Sub(Offset(-w)); only meaningful for fill:none or a post-fill decoration phase, since the fill covers it entirely. Conclusion: ring geometry is the only per-object implementation that composes correctly under transparency (knockout groups being the general, heavier alternative); position (outer default, center for CSS) is the natural parameterization; and a post-fill decoration phase remains necessary for spec-correct stroke-over-fill on filled text. Until FontStroke gains that, these per-face fields carry the CSS use case. Upstream-Status: Pending [alternative: fix FontStroke as above] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
bentleyOttmannwhen building result polygons at vertices where many segments meet (e.g. afterPath.Stroke→Settleon dense, shared-edge geometry such as merged Voronoi cell outlines).path_intersection_voronoi_test.go).Problem
Path.StrokecallsSettle(Positive)on the offset outline. At tight junctions, polygon walking only searched one direction for the next segment at a vertex. When no match was found, the code panicked:next node for result polygon is nil, probably buggy intersection codeTest plan
go test ./...in this repoTestVoronoiCanvasMergedStroke_denseGrid)Made with Cursor