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
@@ -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() };@@ -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]]);+ }+}