Sketch Text (MTextCmd, sketch.edit_text): glyph outlines of the embedded Liberation Sans (SIL OFL 1.1, attributed) as lines and conics that close into extrudable profiles; editable text, height, position and angle

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 22:49:49 +00:00
1 parent 79a4194f6e
commit 2491c40fef
16 files changed
+393 -7

No files matched your search

+4 -1
View File
@@ -34,7 +34,10 @@ pub fn conic_point(a: Vec2, apex: Vec2, b: Vec2, rho: f64, t: f64) -> Vec2 {
}
pub fn conic_polyline(a: Vec2, apex: Vec2, b: Vec2, rho: f64) -> Vec<Vec2> {
let n = SPAN_SAMPLES * 2;
// Samples by how far the curve bows from its chord (small text curves need few).
let chord = b - a;
let bow = chord.cross(apex - a).abs() / chord.len().max(1e-12);
let n = ((bow / 2e-3).sqrt().ceil() as usize).clamp(2, SPAN_SAMPLES * 2);
(0..=n).map(|i| conic_point(a, apex, b, rho, i as f64 / n as f64)).collect()
}
+2
View File
@@ -15,6 +15,7 @@ mod model;
mod profiles;
mod shape;
mod solver;
mod text;
pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, spline_end_tangent, spline_polyline};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
@@ -22,6 +23,7 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, Sk
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections};
pub use solver::{SolveReport, SolveStatus, solve};
pub use text::{MAX_TEXT_CHARS, text_geometry};
#[cfg(test)]
mod tests;
+51 -1
View File
@@ -27,6 +27,8 @@ pub enum LinkKind {
Include,
/// Section curves of a body or face with the sketch plane.
Intersect,
/// Text outlines (drawn like normal geometry; edit the text to change them).
Text,
}
/// What a link refers to. Model geometry is referenced by a point on it plus the body name and
@@ -53,6 +55,14 @@ pub enum LinkSource {
/// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the
/// sketch plane.
Plane { name: String },
/// Text: baseline start `at` (sketch coordinates), capital height and angle (radians).
Text {
text: String,
at: Vec2,
height: f64,
#[serde(default)]
angle: f64,
},
}
impl LinkSource {
@@ -68,6 +78,7 @@ impl LinkSource {
LinkSource::Origin => "origin".into(),
LinkSource::Axis { name } => format!("{name} axis"),
LinkSource::Plane { name } => format!("{name} plane"),
LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::<String>()),
}
}
}
@@ -96,6 +107,13 @@ pub enum LinkGeom {
a: Vec2,
b: Vec2,
},
/// Conic from `a` to `b` with control `apex` (rho 0.5: a quadratic Bézier).
Conic {
a: Vec2,
apex: Vec2,
b: Vec2,
rho: f64,
},
}
impl LinkGeom {
@@ -106,6 +124,7 @@ impl LinkGeom {
LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL,
LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9,
LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL,
LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6,
}
}
}
@@ -123,6 +142,7 @@ enum LCurve {
Line(usize, usize),
Circle(usize, f64),
Arc(usize, usize, usize),
Conic(usize, usize, usize, f64),
}
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
@@ -163,6 +183,12 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
l.curves.push(LCurve::Arc(c, a, b));
}
}
LinkGeom::Conic { a, apex, b, rho } => {
let (a, b, x) = (pt(&mut l.pts, a), pt(&mut l.pts, b), pt(&mut l.pts, apex));
if a != b && x != a && x != b {
l.curves.push(LCurve::Conic(a, b, x, rho));
}
}
}
}
// Lone points that a curve also uses are not lone.
@@ -171,6 +197,7 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
LCurve::Line(a, b) => a == i || b == i,
LCurve::Circle(c, _) => c == i,
LCurve::Arc(c, a, b) => c == i || a == i || b == i,
LCurve::Conic(a, b, x, _) => a == i || b == i || x == i,
})
};
let curves = l.curves.clone();
@@ -179,7 +206,13 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
}
fn same_shape(a: &LCurve, b: &LCurve) -> bool {
matches!((a, b), (LCurve::Line(..), LCurve::Line(..)) | (LCurve::Circle(..), LCurve::Circle(..)) | (LCurve::Arc(..), LCurve::Arc(..)))
matches!(
(a, b),
(LCurve::Line(..), LCurve::Line(..))
| (LCurve::Circle(..), LCurve::Circle(..))
| (LCurve::Arc(..), LCurve::Arc(..))
| (LCurve::Conic(..), LCurve::Conic(..))
)
}
impl Sketch {
@@ -202,6 +235,11 @@ impl Sketch {
self.curves.get(ci).is_some_and(|c| c.link.is_some())
}
/// Is this curve text outline (linked, but drawn like normal geometry)?
pub fn is_text_curve(&self, ci: usize) -> bool {
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.kind == LinkKind::Text)
}
/// Is the curve's link lost (its reference no longer found)?
pub fn is_lost_curve(&self, ci: usize) -> bool {
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost)
@@ -245,6 +283,7 @@ impl Sketch {
LCurve::Line(a, b) => (CurveKind::Line { a: point(self, a)?, b: point(self, b)? }, "l"),
LCurve::Circle(c, r) => (CurveKind::Circle { c: point(self, c)?, r }, "c"),
LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"),
LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"),
};
let cid = self.fresh(prefix);
self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()) });
@@ -274,6 +313,7 @@ impl Sketch {
Some(CurveKind::Line { a, b }) => Some(LCurve::Line(local(*a)?, local(*b)?)),
Some(CurveKind::Circle { c, r }) => Some(LCurve::Circle(local(*c)?, *r)),
Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)),
Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)),
_ => None,
})
.collect();
@@ -297,6 +337,11 @@ impl Sketch {
visit(a);
visit(b);
}
LCurve::Conic(a, b, x, _) => {
visit(a);
visit(b);
visit(x);
}
}
}
for i in &lay.lone {
@@ -310,6 +355,7 @@ impl Sketch {
LCurve::Line(a, b) => LCurve::Line(renum(a), renum(b)),
LCurve::Circle(c, r) => LCurve::Circle(renum(c), r),
LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)),
LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r),
})
.collect();
let same = match &cur {
@@ -322,6 +368,7 @@ impl Sketch {
(LCurve::Line(a0, a1), LCurve::Line(b0, b1)) => a0 == b0 && a1 == b1,
(LCurve::Circle(a0, _), LCurve::Circle(b0, _)) => a0 == b0,
(LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2,
(LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2,
_ => false,
}
})
@@ -342,6 +389,9 @@ impl Sketch {
if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*slot = *r;
}
if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) {
*rho = *r;
}
}
return Ok(false);
}
+129
View File
@@ -0,0 +1,129 @@
//! Sketch text: glyph outlines of an embedded open font (Liberation Sans, SIL OFL 1.1; see
//! `fonts/OFL.txt` and ATTRIBUTION.md) as lines and conics (TrueType quadratic Béziers are
//! conics with rho = 0.5), so text closes into profiles that can be extruded.
use solvecraft_geom::Vec2;
use crate::link::LinkGeom;
use crate::model::{Result, SketchError};
static FONT: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
/// Most characters in one text.
pub const MAX_TEXT_CHARS: usize = 1000;
struct Outline {
out: Vec<LinkGeom>,
start: Vec2,
cur: Vec2,
map: Box<dyn Fn(f32, f32) -> Vec2>,
}
impl Outline {
fn line(&mut self, to: Vec2) {
if self.cur.dist(to) > 1e-9 {
self.out.push(LinkGeom::Line(self.cur, to));
}
self.cur = to;
}
fn quad(&mut self, apex: Vec2, to: Vec2) {
let (a, b) = (self.cur, to);
let chord = b - a;
let off = chord.cross(apex - a).abs() / chord.len().max(1e-12);
if off < 1e-6 || a.dist(b) < 1e-9 {
self.line(to);
return;
}
self.out.push(LinkGeom::Conic { a, apex, b, rho: 0.5 });
self.cur = to;
}
}
impl ttf_parser::OutlineBuilder for Outline {
fn move_to(&mut self, x: f32, y: f32) {
let p = (self.map)(x, y);
self.start = p;
self.cur = p;
}
fn line_to(&mut self, x: f32, y: f32) {
let p = (self.map)(x, y);
self.line(p);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let (c, p) = ((self.map)(x1, y1), (self.map)(x, y));
self.quad(c, p);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
// A cubic as two quadratics (split at the middle).
let (p0, c1, c2, p3) = (self.cur, (self.map)(x1, y1), (self.map)(x2, y2), (self.map)(x, y));
let mid = (p0 + c1 * 3.0 + c2 * 3.0 + p3) / 8.0;
let q1 = p0 + (c1 - p0) * 0.75;
let q2 = p3 + (c2 - p3) * 0.75;
self.quad(q1, mid);
self.quad(q2, p3);
}
fn close(&mut self) {
let s = self.start;
self.line(s);
}
}
/// Outline geometry of `text` with its baseline starting at `at`, capital height `height`
/// (mm), rotated by `angle` (radians) about `at`. Lines break at `\n`.
pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Vec<LinkGeom>> {
if text.chars().count() > MAX_TEXT_CHARS {
return Err(SketchError::TooLarge);
}
if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() {
return Err(SketchError::Invalid("text height must be positive".into()));
}
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
let cap = face.capital_height().map(f64::from).filter(|c| *c > 0.0).unwrap_or(f64::from(face.units_per_em()) * 0.7);
let k = height / cap;
let line_gap = f64::from(face.ascender() - face.descender() + face.line_gap()) * k;
let (s, c) = angle.sin_cos();
let mut out = Vec::new();
for (row, line) in text.split('\n').enumerate() {
let mut pen = 0.0f64;
let base_y = -(row as f64) * line_gap;
for ch in line.chars() {
let Some(gid) = face.glyph_index(ch).or_else(|| face.glyph_index('?')) else { continue };
let x0 = pen;
let map = Box::new(move |x: f32, y: f32| {
let (lx, ly) = (x0 + f64::from(x) * k, base_y + f64::from(y) * k);
at + Vec2::new(lx * c - ly * s, lx * s + ly * c)
});
let mut ob = Outline { out: Vec::new(), start: at, cur: at, map };
let _ = face.outline_glyph(gid, &mut ob);
out.extend(ob.out);
pen += f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letters_have_closed_outlines_at_the_cap_height() {
let g = text_geometry("HO", Vec2::new(5.0, 0.0), 10.0, 0.0).unwrap();
assert!(!g.is_empty());
let ys: Vec<f64> = g
.iter()
.flat_map(|x| match *x {
LinkGeom::Line(a, b) => vec![a.y, b.y],
LinkGeom::Conic { a, b, .. } => vec![a.y, b.y],
_ => vec![],
})
.collect();
let top = ys.iter().cloned().fold(f64::MIN, f64::max);
assert!((top - 10.0).abs() < 0.3, "{top}");
assert!(g.iter().any(|x| matches!(x, LinkGeom::Conic { .. })), "O has curves");
assert!(text_geometry("x", Vec2::ZERO, -1.0, 0.0).is_err());
assert!(text_geometry(&"x".repeat(2000), Vec2::ZERO, 1.0, 0.0).is_err());
// Empty text makes nothing (not an error here; the command rejects it).
assert!(text_geometry("", Vec2::ZERO, 1.0, 0.0).unwrap().is_empty());
}
}