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

+4 -1
View File
@@ -47,7 +47,10 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, M
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use periodic::periodic_beziers;
pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves};
pub use profiles::{
CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves,
without_text_counters,
};
pub use shape::{Shape, intersections};
pub use solver::{
Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with,
+73 -8
View File
@@ -413,22 +413,80 @@ fn curvature(s: &Seg2) -> f64 {
}
}
/// Find all closed profiles of a sketch. Curves are split where other curves end on them or
/// cross them, like a drawing would be read.
/// Profiles of the drawn geometry only (linked reference curves left out).
/// Profiles of the drawn geometry only: linked reference curves (projections, intersections)
/// are left out; text outlines count as drawn.
pub fn find_drawn_profiles(sk: &Sketch) -> Vec<Profile> {
if sk.links.is_empty() {
if sk.links.iter().all(|l| l.kind == crate::LinkKind::Text) {
return find_profiles(sk);
}
let mut d = sk.clone();
for c in &mut d.curves {
if c.link.is_some() {
for (ci, c) in d.curves.iter_mut().enumerate() {
if c.link.is_some() && !sk.is_text_curve(ci) {
c.construction = true;
}
}
find_profiles(&d)
}
/// The profiles without text counters: a profile bounded by text outlines alone that lies inside
/// an odd number of other such profiles is the counter of a letter (the hole of A, B, 8...),
/// which the letter's own profile already has as a hole. What "all profiles" means for text:
/// the letters, not the letters with their counters filled in.
pub fn without_text_counters(sk: &Sketch, profiles: Vec<Profile>) -> Vec<Profile> {
let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
let text_only: Vec<bool> = profiles
.iter()
.map(|p| !p.outer_curves.is_empty() && p.outer_curves.iter().all(|id| index.get(id.as_str()).is_some_and(|ci| sk.is_text_curve(*ci))))
.collect();
if !text_only.iter().any(|t| *t) {
return profiles;
}
// Outer polylines and their boxes, once.
let polys: Vec<Vec<Vec2>> = profiles.iter().map(|p| p.region.outer.polyline(1e-3)).collect();
let boxes: Vec<(Vec2, Vec2)> = polys
.iter()
.map(|pl| {
pl.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
})
})
.collect();
let areas: Vec<f64> = profiles.iter().map(|p| p.region.outer.signed_area().abs()).collect();
let inside = |pl: &[Vec2], p: Vec2| {
let n = pl.len();
let mut odd = false;
for i in 0..n {
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) {
odd = !odd;
}
}
odd
};
let keep: Vec<bool> = (0..profiles.len())
.map(|i| {
if !text_only.get(i).copied().unwrap_or(false) {
return true;
}
let Some(probe) = profiles.get(i).and_then(|p| p.region.outer.segs.first()).map(Seg2::mid) else { return true };
let ai = areas.get(i).copied().unwrap_or(0.0);
let depth = (0..profiles.len())
.filter(|&j| {
j != i
&& text_only.get(j).copied().unwrap_or(false)
&& areas.get(j).copied().unwrap_or(0.0) > ai
&& boxes.get(j).is_some_and(|(lo, hi)| probe.x >= lo.x && probe.x <= hi.x && probe.y >= lo.y && probe.y <= hi.y)
&& polys.get(j).is_some_and(|pl| inside(pl, probe))
})
.count();
depth % 2 == 0
})
.collect();
profiles.into_iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| p).collect()
}
/// Find all closed profiles of a sketch. Curves are split where other curves end on them or
/// cross them, like a drawing would be read.
pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
find_profiles_bounded(sk, MAX_EDGES)
}
@@ -981,7 +1039,10 @@ fn same_seg(a: &Seg2, b: &Seg2, tol: f64) -> bool {
&& a.end().dist(b.end()) <= tol
&& a.mid().dist(b.mid()) <= tol
}
_ => false,
// Free-form segments (text outlines, ellipses, splines): the same curve when they run
// through the same points.
(Seg2::Line { .. } | Seg2::Arc { .. }, _) | (_, Seg2::Line { .. } | Seg2::Arc { .. }) => false,
_ => [0.0, 0.25, 0.5, 0.75, 1.0].iter().all(|t| a.point_at(*t).dist(b.point_at(*t)) <= tol),
}
}
@@ -1090,8 +1151,12 @@ pub fn merge_regions(regions: &[Region2]) -> Vec<Region2> {
None => break,
}
}
// A loop that encloses nothing (pieces running back over themselves) is no boundary.
if !lp.is_empty() && lp.first().zip(lp.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= tol) {
loops.push(Loop2 { segs: rejoined(lp, &rejoin) });
let lp = Loop2 { segs: rejoined(lp, &rejoin) };
if lp.signed_area().abs() > 1e-9 {
loops.push(lp);
}
}
}
let outers: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() > 0.0).collect();
+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};