Skip to content

Fix glyphs without triangles for self-overlapping font contours (Bahnschrift Light "e") - #352

Merged
dsn27 merged 2 commits into
masterfrom
claude/bahnschrift-glyph-triangulation-33r35a
Sep 6, 2026
Merged

dsn27 merged 2 commits into
masterfrom
claude/bahnschrift-glyph-triangulation-33r35a

Conversation

@dsn27

@dsn27 dsn27 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

With TriangulateText == true some glyphs are missing in rendered text, observed with "Bahnschrift Light" and the letter "e": FontCache.Get produced an empty display list, CloseList returned null, and because null was not treated as a cached result the glyph was triangulated again on every repaint.

Cause

Variable fonts (Bahnschrift, Roboto Flex, Source Sans 3, Recursive, Inter, …) keep overlapping contours. Glyphs like "e", "6", "9", "P", "&" are drawn as a single contour that overlaps itself: bowl and bar are one closed path, the eye of the "e" is not a contour of its own but only the region enclosed by the overlap. GetOutline2D delivers this contour correctly as one closed path, SimpleShape/Face.MakeFace accept it, but Face.Triangulate cannot triangulate a self-intersecting border and returns no triangles. Nothing in FontCache.Get noticed this.

Reproduced on Linux with the glyph outlines of Roboto Flex Light, Source Sans 3 Light and Recursive Light (extracted with fontTools, fed through the same path conversion as GDI+ data): "e", "6", "9" (Roboto Flex, Source Sans 3), "B", "D", "e" (Recursive) and "P" (Inter) had 0 triangles with the old code. Bahnschrift itself is not available outside Windows; its "e" uses the same construction (the glyph has one contour with an overlap flag), so this is the failure you traced.

A second, previously ignored case: separate contours that overlap each other (e.g. "K", "A", "R" of Roboto Flex, the "raumTalk" case mentioned in the code). The old code only subtracted contained contours and ignored intersecting ones.

Fix

  • New GlyphShapeBuilder (internal, GDI-free): converts the GDI+ path data to Path2D and builds the glyph area with the non-zero winding rule of fonts:
    • a self-intersecting contour is split at its intersections into a planar graph; the faces with non-zero winding number are the filled area, adjacent faces are united over their shared edges (so the result is one simple shape with holes, not a pile of touching faces);
    • separate overlapping contours are united when they have the same orientation as the outer contour, subtracted when opposite;
    • nested contours are handled exactly as before (containment only, orientation is ignored because some fonts are inconsistent). Verified with 14 font files × 94 glyphs: for every glyph without overlaps the resulting shape is identical to the old algorithm.
  • FontCache.Get(…, IPaintTo3D) safety net (logic of 0002-fontcache-outline-fallback-for-glyphs-without-face.patch from LogiCal, extended): the triangulation of every part is checked with Face.GetTriangulation; a part without triangles is painted by its outline, a glyph without any face by its paths; exceptions of a single glyph are caught; the display list is closed in a finally and the result is cached even when the list is null (DictVal.listCreated), so a failure is not repeated every frame.
  • The three copies of the shape construction in FontCache (Get, Get with IPaintTo3D, GetCenterLines) now share one implementation. As a side effect the IPaintTo3D variant no longer loses islands inside holes (like the "c" of "©"), which the GetCenterLines variant had already fixed.

Tests

  • GlyphShapeBuilderTests: synthetic contours (hole, island in hole, overlapping "+", opposite orientation, keyhole hole, figure eight) and the real "e" of Roboto Flex Light (OFL) at all three font precisions. Run on Linux, all pass.
  • FontCacheGlyphTests: sends 0x20–0x7E of Arial, Calibri, Bahnschrift, Bahnschrift Light, Segoe UI Variable, Segoe UI through FontCache.Get with a recording IPaintTo3D and expects triangles for every glyph with a closed outline, plus the cache check; Inconclusive when GDI or the font is not available. An ignored survey test reports all installed fonts. These need a Windows machine with the fonts, they could not be executed here.
  • CADability.Tests is signed with CADabilityKey.snk and gets InternalsVisibleTo in CADability.

Font check (item 4 of the task)

Outside Windows only substitutes were possible. Result with the new code, all 94 printable ASCII glyphs each: Barlow Light, DejaVu Sans, FreeSans, Liberation Sans, Noto Sans Light, Open Sans Light/Bold, Oswald Light, Inter Light/Regular, Recursive Sans Linear Light, Roboto Flex Light/Regular, Source Sans 3 Light: 0 glyphs without triangles, 0 area mismatches against skia-pathops as reference. Before the change: Roboto Flex "69e", Source Sans 3 "69e", Recursive "BDe", Inter "P" without triangles. Bahnschrift, Segoe UI Variable, Calibri and Arial must be checked with FontCacheGlyphTests on Windows.

The version is bumped to 1.1.10 in a separate commit on this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QL5zfiEqxsCepsdBixLDS1


Generated by Claude Code

dsn27 and others added 2 commits September 6, 2026 16:15
Variable fonts like Bahnschrift Light, Roboto Flex, Source Sans 3 or
Recursive draw glyphs such as "e", "6", "9" as a single contour that
overlaps itself (bowl and bar in one path, the eye is only formed by the
overlap). Face.MakeFace/Triangulate cannot triangulate such a border, so
FontCache.Get produced an empty display list, CloseList returned null,
the glyph was missing and, because null was not cached, it was
triangulated again on every repaint.

- New GlyphShapeBuilder (GDI-free, testable): converts the GDI+ path data
  to Path2D and builds the glyph area with the non-zero winding rule.
  Self intersecting contours are split at their intersections into a
  planar graph, the faces with non-zero winding number are united.
  Separate overlapping contours are united (same orientation as the
  outer contour) or subtracted (opposite orientation) instead of being
  ignored. Nested contours are handled exactly as before.
- FontCache.Get: checks the triangulation of every part, paints the
  outline of a part without triangles (or of the whole glyph when no face
  could be made), catches exceptions of a single glyph, closes the display
  list in any case and caches the result even when the list is null.
- FontCache: the three copies of the shape construction (Get, Get with
  IPaintTo3D, GetCenterLines) share GlyphShapeBuilder.MakeShape.
- Tests: GlyphShapeBuilderTests (synthetic contours and the "e" of Roboto
  Flex Light, no font needed), FontCacheGlyphTests (all characters 0x20
  to 0x7E of installed fonts through FontCache.Get, inconclusive when the
  font or GDI is not available). CADability.Tests is signed with the
  CADability key and gets InternalsVisibleTo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL5zfiEqxsCepsdBixLDS1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL5zfiEqxsCepsdBixLDS1
@dsn27
dsn27 merged commit 6d43d18 into master Sep 6, 2026
1 check passed

This branch was successfully deployed

1 active deployment
github-pages — 6d43d184 Deployed Sep 6, 2026 by dsn27 via deploy #41
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.

1 participant