Skip to content

Fix Bentley-Ottmann polygon walk at shared vertices - #382

Open
aldernero wants to merge 2 commits into
tdewolff:masterfrom
aldernero:master
Open

Fix Bentley-Ottmann polygon walk at shared vertices#382
aldernero wants to merge 2 commits into
tdewolff:masterfrom
aldernero:master

Conversation

@aldernero

Copy link
Copy Markdown
Contributor

Summary

  • Fix panics in bentleyOttmann when building result polygons at vertices where many segments meet (e.g. after Path.StrokeSettle on dense, shared-edge geometry such as merged Voronoi cell outlines).
  • Search both directions around the snap-square event ring for the next result segment, with a fallback to another left endpoint at the vertex.
  • Close partial contours to the other endpoint instead of panicking when no neighbor is found.
  • Add regression tests for merged grid stroke/settle (path_intersection_voronoi_test.go).

Problem

Path.Stroke calls Settle(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 code

Test plan

  • go test ./... in this repo
  • Reproduced originally via merged Voronoi cell stroke in gaul (TestVoronoiCanvasMergedStroke_denseGrid)

Made with Cursor

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>
@tdewolff

Copy link
Copy Markdown
Owner

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?

anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Jun 19, 2026
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>
@aldernero

Copy link
Copy Markdown
Contributor Author

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 panic

The bug requires floating-point imprecision in the vertex coordinates. With a manually-crafted grid all coordinates land exactly on the snap-rounding grid (BentleyOttmannEpsilon = 1e-8), so the event ring is well-behaved and the one-directional search always succeeds.

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 0.0 comes out as 1.3877787807814457e-17. After stroking 100 merged cells at width 0.2, the pre-Settle stroke outline has 1487 commands instead of the expected 1440; those 47 extra near-coincident segments create the vertex configuration where the CCW search finds nothing and the one-directional search panics.

What's in the updated PR

I replaced the four synthetic tests (which all passed on the pre-fix code) with:

  1. TestSettleMergedVoronoiLikeGrid — keeps the basic "settle the merged grid" smoke-test (NonZero rule, always passed, still passes).
  2. TestSettleVoronoiDenseGridStrokeOutline — the canonical regression test. It loads testdata/voronoi_stroke_pre_settle.gob, which is the gob-encoded stroke outline from 100 Voronoi cells (generated by github.com/aldernero/gaul and captured before the internal Settle call). Calling p.Settle(Positive) on this fixture panics on the pre-fix code with next node for result polygon is nil and passes with the fix.

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.gob

Let 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).

anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Jul 26, 2026
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.
@tdewolff

Copy link
Copy Markdown
Owner

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.

anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Aug 14, 2026
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.
anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Aug 18, 2026
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
anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Aug 18, 2026
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
anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Aug 18, 2026
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]
tdewolff added a commit that referenced this pull request Aug 19, 2026
anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Aug 30, 2026
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>
anaelorlinski added a commit to anaelorlinski/tdewolff-canvas that referenced this pull request Aug 30, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants