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

+1
View File
@@ -18,6 +18,7 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
## Code adapted from sibling projects
Generated
+7
View File
@@ -3235,6 +3235,7 @@ dependencies = [
"serde_json",
"solvecraft-geom",
"thiserror 2.0.21",
"ttf-parser",
]
[[package]]
@@ -3616,6 +3617,12 @@ dependencies = [
"truck-geotrait",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "type-map"
version = "0.5.1"
+3
View File
@@ -41,6 +41,9 @@ truck-topology = "0.6"
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.38"
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# images
image = { version = "0.25", default-features = false, features = ["png"] }
+1
View File
@@ -136,6 +136,7 @@ pub fn resolve(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState,
_ => vec![LinkGeom::Point(o)],
}
}
LinkSource::Text { text, at, height, angle } => solvecraft_sketch::text_geometry(text, *at, *height, *angle)?,
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
+68 -1
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch};
use solvecraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
use super::{CommandSpec, in_sketch};
@@ -70,8 +70,75 @@ pub static COMMANDS: &[CommandSpec] =
.icon("conic")
.enabled(in_sketch)
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
CommandSpec::new("MTextCmd", "Text", text)
.at("SKETCH", "CREATE")
.icon("text")
.enabled(in_sketch)
.params("text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg"),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.params("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg"),
];
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
if text.trim().is_empty() {
return Err(bad(cmd, "`text` must not be empty"));
}
if text.chars().count() > solvecraft_sketch::MAX_TEXT_CHARS {
return Err(bad(cmd, "text too long"));
}
let at = p.get("at").and_then(crate::params::vec2).unwrap_or(at0);
let height = num(p, "height").unwrap_or(h0);
if !(height > 1e-6 && height < 1e5) {
return Err(bad(cmd, "`height` must be positive"));
}
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
Ok(LinkSource::Text { text, at, height, angle })
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MTextCmd";
if p.get("at").is_none() {
return Err(bad(cmd, "`at` must be the baseline start [x, y]"));
}
let src = text_source(p, cmd, None)?;
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let l = super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, src)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(json!({"link": l, "curves": curves}))
}
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let l = match (str_(p, "link"), str_(p, "entity")) {
(Some(l), _) => l.to_string(),
(None, Some(e)) => sk.curves.iter().find(|c| c.id == e).and_then(|c| c.link.clone()).ok_or_else(|| bad(cmd, format!("`{e}` is not text")))?,
_ => return Err(bad(cmd, "give `link` or `entity`")),
};
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle })) => (text, at, height, angle),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
let geom = solvecraft_sketch::text_geometry(text, *at, *height, *angle)?;
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(json!({"link": l, "curves": curves}))
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleElipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
@@ -260,3 +260,27 @@ fn ellipse_splines_conic() {
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m}));
assert_eq!(ids(&r["curves"]).len(), 2);
}
#[test]
fn text_makes_profiles_that_extrude_and_edit() {
let mut s = new_sketch();
let r = run(&mut s, "MTextCmd", json!({"text": "SO", "at": [0, 0], "height": 10}));
let link = r["link"].as_str().unwrap().to_string();
let a = profiles(&s);
// S (one region) + O (ring = outer with a hole, plus the counter as its own region).
assert!(a.len() >= 2, "{a:?}");
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0);
// Edit the text: the outlines change, the link stays.
let r2 = run(&mut s, "sketch.edit_text", json!({"link": link, "text": "I", "height": 20}));
assert_eq!(r2["link"], json!(link));
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let m = run(&mut s, "MeasureCommand", json!({}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
// An "I" 20 mm tall is a bar a couple of mm wide.
assert!(v > 40.0 && v < 200.0, "{v}");
assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err());
}
+1
View File
@@ -281,6 +281,7 @@ fn hostile_params_never_panic() {
"minor_radius",
"apex",
"rho",
"text",
];
let mut internal = Vec::new();
for spec in command_specs() {
+1 -1
View File
@@ -69,7 +69,7 @@ pub fn grid_step(half_height: f64) -> (f64, f64) {
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
let mut out = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
let col = if c.link.is_some() {
let col = if c.link.is_some() && !sk.is_text_curve(i) {
if sk.is_lost_curve(i) { colors::SKETCH_LOST } else { colors::SKETCH_PROJECTED }
} else if c.construction {
colors::SKETCH_CONSTRUCTION
+1
View File
@@ -12,6 +12,7 @@ workspace = true
solvecraft-geom = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
ttf-parser = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
Liberation Sans (crates/sketch/fonts/LiberationSans-Regular.ttf)
Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+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());
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
**SOLID + SKETCH: 89 / 291 commands live (31%).** All Design tabs: 182 / 1175 (15%). Registered commands: 115.
**SOLID + SKETCH: 90 / 291 commands live (31%).** All Design tabs: 183 / 1175 (16%). Registered commands: 117.
| Tab | Panel | Live | Total | % |
|---|---|---:|---:|---:|
@@ -15,7 +15,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
| SOLID | INSERT | 1 | 11 | 9% |
| SOLID | SELECT | 0 | 15 | 0% |
| SOLID | POSITION | 0 | 2 | 0% |
| SKETCH | CREATE | 34 | 41 | 83% |
| SKETCH | CREATE | 35 | 41 | 85% |
| SKETCH | MODIFY | 11 | 12 | 92% |
| SKETCH | CONSTRAINTS | 11 | 15 | 73% |
| SKETCH | CONFIGURE | 0 | 3 | 0% |
@@ -158,6 +158,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
- SKETCH › CREATE › Control Point Spline (`DrawCVMSpline5D`)
- SKETCH › CREATE › Conic Curve (`ConicCurveCmd`)
- SKETCH › CREATE › Point (`DrawPoint`)
- SKETCH › CREATE › Text (`MTextCmd`)
- SKETCH › CREATE › Mirror (`MirrorSketchCommand`)
- SKETCH › CREATE › Circular Pattern (`CircularSketchPatternCommand`)
- SKETCH › CREATE › Rectangular Pattern (`RectangularSketchPatternCommand`)
@@ -330,7 +331,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool
- SOLID › POSITION › Capture Position (`SnapshotCmd`)
- SOLID › POSITION › Revert Position (`AsBuiltPositionsCmd`)
- SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`)
- SKETCH › CREATE › Text (`MTextCmd`)
- SKETCH › CREATE › Fit Curves to Mesh Section (`FitCurvesToSectionCommand`)
- SKETCH › CREATE › Spun Profile (`SpunProfileCmd`)
- SKETCH › CREATE › Project To Surface (`ProjectToSurface`)