fix: text extrudes as letters with their counters, alone or beside drawn curves (#502)

Text outlines count as drawn geometry when picking profiles, and "all profiles" leaves out the counters of letters (the holes of A, B, 8), so text extrudes as letters with their holes whether or not other curves are drawn next to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-13 00:25:39 -04:00
1 parent f93577ad70
commit c2a9c71436
5 files changed
+229 -11

No files matched your search

+82
View File
@@ -339,6 +339,88 @@ fn merging_adjacent_regions() {
assert!(sides.contains(&4) && sides.contains(&3), "{sides:?}");
}
/// A sketch holding `text` the way `sketch.text` makes it: one text link of the built-in font's
/// outlines.
fn text_sketch(text: &str, at: Vec2, height: f64) -> Sketch {
let mut sk = Sketch::new();
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0 };
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
sk
}
fn sorted_areas(ps: &[Profile]) -> Vec<f64> {
let mut a: Vec<f64> = ps.iter().map(|p| p.area).collect();
a.sort_by(f64::total_cmp);
a
}
fn assert_areas(got: &[f64], mut want: Vec<f64>) {
want.sort_by(f64::total_cmp);
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
for (g, w) in got.iter().zip(&want) {
assert!((g - w).abs() < 2e-3, "{got:?} against {want:?}");
}
}
#[test]
fn text_counters_are_holes_of_their_letters_not_profiles_of_their_own() {
// Areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
// formula, quadratic pieces flattened finely): a glyph's outer contour, then its counters.
// O: 83.3133 - 47.4584 A: 39.2159 - 9.0647 B: 68.9283 - 12.9336 - 16.6277
// 8: 56.8394 - 10.6451 - 13.7347
let (o, o_counter) = (83.3133 - 47.4584, 47.4584);
let (a, a_counter) = (39.2159 - 9.0647, 9.0647);
let (b, b_counters) = (68.9283 - 12.9336 - 16.6277, [12.9336, 16.6277]);
let (eight, eight_counters) = (56.8394 - 10.6451 - 13.7347, [10.6451, 13.7347]);
let check = |text: &str, letters: Vec<f64>, counters: Vec<f64>| {
let sk = text_sketch(text, Vec2::ZERO, 10.0);
let all = find_drawn_profiles(&sk);
// Every counter is a closed region of its own, next to its letter with the hole in it...
assert_areas(&sorted_areas(&all), letters.iter().chain(&counters).copied().collect());
// ...and "all profiles" of text means the letters.
assert_areas(&sorted_areas(&without_text_counters(&sk, all)), letters);
};
check("O", vec![o], vec![o_counter]);
check("8", vec![eight], eight_counters.to_vec());
check("AB", vec![a, b], [vec![a_counter], b_counters.to_vec()].concat());
// A letter without a counter is untouched.
check("I", vec![13.5557], vec![]);
}
#[test]
fn text_beside_other_curves_is_drawn_geometry() {
// A 30 x 20 rectangle with an "I" (a 10 mm tall bar, 13.5557 mm² in Liberation Sans) beside it.
let mut sk = text_sketch("I", v(40.0, 0.0), 10.0);
for (p, q) in [((0.0, 0.0), (30.0, 0.0)), ((30.0, 0.0), (30.0, 20.0)), ((30.0, 20.0), (0.0, 20.0)), ((0.0, 20.0), (0.0, 0.0))] {
sk.add_line(v(p.0, p.1), v(q.0, q.1), None, None, None).unwrap();
}
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]);
// Projected reference geometry still doesn't count as drawn.
let square = |s: f64| {
vec![
LinkGeom::Line(v(100.0, 0.0), v(100.0 + s, 0.0)),
LinkGeom::Line(v(100.0 + s, 0.0), v(100.0 + s, s)),
LinkGeom::Line(v(100.0 + s, s), v(100.0, s)),
LinkGeom::Line(v(100.0, s), v(100.0, 0.0)),
]
};
sk.add_link(LinkKind::Project, LinkSource::Origin, &square(5.0)).unwrap();
assert_eq!(find_profiles(&sk).len(), 3);
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]);
}
#[test]
fn a_letter_merged_with_its_counter_is_the_filled_letter() {
// The ring of an O and its counter share the counter's two conics; merged, they cancel along it.
let sk = text_sketch("O", Vec2::ZERO, 10.0);
let regions: Vec<_> = find_profiles(&sk).into_iter().map(|p| p.region).collect();
assert_eq!(regions.len(), 2);
let merged = merge_regions(&regions);
assert_eq!(merged.len(), 1, "{} regions", merged.len());
assert!(merged[0].holes.is_empty(), "{} holes", merged[0].holes.len());
assert!((merged[0].area() - 83.3133).abs() < 2e-3, "{}", merged[0].area());
}
#[test]
fn links_update_in_place_rebuild_and_break() {
use crate::{LinkGeom, LinkKind, LinkSource};