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 <[email protected]>
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 -2
View File
@@ -1063,10 +1063,12 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
}
let out: Vec<Region2> = match sel {
// "All" means what was drawn: projected reference geometry (a face's auto-projected
// edges) only counts when the drawn curves alone make no profile.
// edges) only counts when the drawn curves alone make no profile. Text is drawn, and
// its letters keep their counters as holes (the counters are not filled in).
ProfileSel::All => {
let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch);
if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.into_iter().map(|p| p.region).collect() }
let chosen = if drawn.is_empty() { ps.clone() } else { drawn };
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
}
ProfileSel::Indices { indices } => {
let mut v = Vec::new();
@@ -429,6 +429,72 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
}
/// Volumes (mm³) of the bodies in the design, smallest first.
fn body_volumes(s: &mut Session) -> Vec<f64> {
let m = run(s, "inspect.measure", json!({}));
let mut v: Vec<f64> = m["bodies"].as_array().unwrap().iter().map(|b| b["volume_mm3"].as_f64().unwrap()).collect();
v.sort_by(f64::total_cmp);
v
}
#[test]
fn text_extrudes_with_its_counters_and_beside_other_curves() {
// Letter areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
// formula, quadratic pieces flattened finely), each outer contour minus its counters:
// A 39.2159 - 9.0647 B 68.9283 - 12.9336 - 16.6277 8 56.8394 - 10.6451 - 13.7347 I 13.5557
// Extruded 2 mm, so the volumes are twice that. Faceted walls: within 0.02 mm³.
let near = |got: &[f64], want: &[f64]| {
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.02), "{got:?} against {want:?}");
};
// Letters with counters, alone: a letter is one body with its counters as holes. (A counter used to be
// extruded as a body of its own, or the extrude failed with "profile has no area".)
for (text, want) in
[("AB", vec![2.0 * (39.2159 - 9.0647), 2.0 * (68.9283 - 12.9336 - 16.6277)]), ("8", vec![2.0 * (56.8394 - 10.6451 - 13.7347)])]
{
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": text, "at": [0, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
near(&body_volumes(&mut s), &want);
}
// Text beside a drawn rectangle is extruded with it (it used to be left out): the 30 x 20 x 2 plate and an "I".
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
run(&mut s, "sketch.text", json!({"text": "I", "at": [40, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
near(&body_volumes(&mut s), &[2.0 * 13.5557, 1200.0]);
// A counter can still be picked on its own, by a point inside it: the inner contour of an O, 47.4584 mm².
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": "O", "at": [0, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [{"point": [5.65, 5.0]}]}));
near(&body_volumes(&mut s), &[2.0 * 47.4584]);
}
#[test]
fn text_embossed_or_debossed_leaves_its_counters_alone() {
// A 40 x 30 x 5 plate (6000 mm³) with 10 mm tall text raised from, or cut 1 mm into, its top. Only the letters
// change the volume, by their area in mm² (see above) x 1 mm; the counters of the O and of A and B stay as
// they are. (Emboss and deboss used to fail with "boolean Cut: no result" or "boolean Union: no result",
// because each counter was a tool body of its own, filling the hole of its letter.)
let (o, ab) = (83.3133 - 47.4584, (39.2159 - 9.0647) + (68.9283 - 12.9336 - 16.6277));
for (text, mode, change) in [("O", "deboss", -o), ("O", "emboss", o), ("AB", "deboss", -ab)] {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "body_name": "Plate"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Lid"}));
run(&mut s, "sketch.create", json!({"plane": "Lid", "name": "Text"}));
run(&mut s, "sketch.text", json!({"text": text, "at": [10, 10], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Text", "depth": 1, "mode": mode}));
let v = body_volumes(&mut s);
assert!(v.len() == 1 && (v[0] - (6000.0 + change)).abs() < 0.02, "{text} {mode}: {v:?} against {}", 6000.0 + change);
}
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
+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};