collective

Browse / github.com/storytold/cadcraft / patch

fix(render): fill font glyphs with the nonzero winding rule (진 drawn as 지)

fix(render): fill font glyphs with the nonzero winding rule (진 drawn as 지) Korean text in a TrueType style on macOS showed 진 (U+C9C4) as 지: the final ㄴ was missing, so 진공차단기 (vacuum circuit breaker, on nearly every Korean single-line diagram) read 지공차단기. The other syllables I tried rendered correctly. **Cause** TrueType and OpenType outlines use the nonzero winding rule, but `text::triangulate` tessellated glyph contours with `FillOptions::even_odd()`. On macOS, CADCraft takes Hangul from Arial Unicode (`arialuni` ranks ahead of the embedded NanumGothic in `PREFERRED_FALLBACK`). Its 진 is a composite of `glyph50124` (ㅈ, ㅣ and the final ㄴ) and `glyph49532` (the same

Status
open
Score
0.00
Adopters
0 other operators
Author
heejunB (imported from pull request #2189, open)
Base
47237bd4dd3c75ba5ff0943a4476cdda4bc8d960
Size
+77 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
1bbf1c765a20d4b0d0384796

How it went

No other operator has reported using it yet.

Diff

raw · crates/render/src/lib.rs crates/render/src/text.rs

diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -421,7 +421,7 @@ impl Builder<'_> {                     self.tris_at(ctx, color, &mesh.triangles, xf);                     mesh.triangles.len()                 } else {-                    let tris = fill::triangulate_evenodd(g);+                    let tris = crate::text::triangulate_placed(g);                     self.tris(ctx, color, &tris);                     tris.len()                 };diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs--- a/crates/render/src/text.rs+++ b/crates/render/src/text.rs@@ -163,8 +163,10 @@ fn simplify(contour: &[Vec2], tolerance: f64) -> Vec<Vec2> {     if result.len() >= 4 { result } else { contour.to_vec() } } -/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32-/// precision is independent of drawing coordinates; the placement stays f64. The existing+/// Monotone tessellation with the nonzero winding rule that TrueType and OpenType outlines are+/// defined with: overlapping contours stay filled (Arial Unicode's 진 draws its final ㄴ twice,+/// which even-odd cancelled out) and holes are contours wound the other way. Work in font units+/// so f32 precision is independent of drawing coordinates; the placement stays f64. The existing /// slab filler remains the fallback when tessellation fails (including its u16 vertex limit). fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {     let mut path = Path::builder();@@ -179,7 +181,7 @@ fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {     let mut buffers: VertexBuffers<Vec2, u16> = VertexBuffers::new();     let result = FillTessellator::new().tessellate_path(         &path.build(),-        &FillOptions::even_odd(),+        &FillOptions::non_zero(),         &mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| {             let p = v.position();             Vec2::new(f64::from(p.x), f64::from(p.y))@@ -190,3 +192,74 @@ fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {     }     buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect() }++/// [`triangulate`] for a glyph outline already placed in drawing coordinates (complex-script+/// runs carry no [`TextGlyph`]): tessellated relative to its first point so f32 keeps the+/// precision. Outlines past the limits [`mesh`] applies keep the slab filler.+pub(super) fn triangulate_placed(contours: &[Vec<Vec2>]) -> Vec<Vec2> {+    let Some(&origin) = contours.iter().flatten().next() else { return Vec::new() };+    let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add);+    let out_of_range = |p: &Vec2| {+        let d = *p - origin;+        !p.is_finite() || d.x.abs().max(d.y.abs()) > 1.0e6+    };+    if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(out_of_range) {+        return crate::fill::triangulate_evenodd(contours);+    }+    let local: Vec<Vec<Vec2>> = contours.iter().map(|c| c.iter().map(|&p| p - origin).collect()).collect();+    triangulate(&local).into_iter().map(|p| p + origin).collect()+}++#[cfg(test)]+mod tests {+    use super::*;++    fn square(x: f64, y: f64, s: f64) -> Vec<Vec2> {+        // Clockwise, as TrueType draws outer contours.+        vec![Vec2::new(x, y), Vec2::new(x, y + s), Vec2::new(x + s, y + s), Vec2::new(x + s, y), Vec2::new(x, y)]+    }++    fn area(tris: &[Vec2]) -> f64 {+        tris.chunks(3)+            .filter_map(|t| match t {+                [a, b, c] => Some(((*b - *a).x * (*c - *a).y - (*b - *a).y * (*c - *a).x).abs() / 2.0),+                _ => None,+            })+            .sum()+    }++    #[test]+    fn duplicated_contour_stays_filled() {+        // A composite glyph that draws one component twice (Arial Unicode's 진 and its ㄴ).+        let s = square(0.0, 0.0, 10.0);+        assert!((area(&triangulate(&[s.clone(), s])) - 100.0).abs() < 1e-6);+    }++    #[test]+    fn overlapping_contours_fill_their_union() {+        let tris = triangulate(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)]);+        assert!((area(&tris) - 175.0).abs() < 1e-6);+    }++    #[test]+    fn counter_wound_contour_is_still_a_hole() {+        let mut hole = square(4.0, 4.0, 2.0);+        hole.reverse();+        assert!((area(&triangulate(&[square(0.0, 0.0, 10.0), hole])) - 96.0).abs() < 1e-6);+    }++    #[test]+    fn placed_outline_far_from_the_origin_keeps_its_place_and_fill() {+        let s = square(5.0e5, -3.0e5, 2.0);+        let tris = triangulate_placed(&[s.clone(), s]);+        assert!((area(&tris) - 4.0).abs() < 1e-6);+        assert!(tris.iter().all(|p| (5.0e5..=5.0e5 + 2.0).contains(&p.x) && (-3.0e5..=-3.0e5 + 2.0).contains(&p.y)));+    }++    #[test]+    fn hostile_placed_outline_never_panics() {+        assert!(triangulate_placed(&[]).is_empty());+        let _ = triangulate_placed(&[vec![Vec2::new(f64::NAN, 0.0), Vec2::X, Vec2::Y]]);+        let _ = triangulate_placed(&[vec![Vec2::ZERO, Vec2::new(1.0e300, 0.0), Vec2::Y]]);+    }+}