feat: sketch text takes a font, an installed family or a font file (#516)
sketch.text and sketch.edit_text take an optional font: an installed family or a .ttf/.otf/.ttc file. Edit Text gets a Font field. The font is kept with the text under format 16; no fonts are bundled, and the font search is bounded. Fixes #513 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7d00df0a3f
commit
a69c2928f0
15 files changed
+883
-41
No files matched your search
@@ -55,6 +55,11 @@
|
||||
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
|
||||
//! notch they always had); the version stamp lets older readers request an update instead of
|
||||
//! silently building notches where a design asks for another shape.
|
||||
//! Under the same version, sketch text can be set in a font other than the built-in one
|
||||
//! (`font` in the text link's source: an installed family or a font file); a text without
|
||||
//! `font` is in the built-in font, as it always was.
|
||||
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
|
||||
//! move without it saves as before.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ fn resolve_uncached(
|
||||
LinkSource::SketchAxis { name } => {
|
||||
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
|
||||
LinkSource::Text { text, at, height, angle, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), 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() } };
|
||||
|
||||
@@ -80,16 +80,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
|
||||
.params(concat!(
|
||||
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
|
||||
created_doc!()
|
||||
)),
|
||||
];
|
||||
|
||||
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));
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
|
||||
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"));
|
||||
@@ -103,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
|
||||
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 })
|
||||
let font = match p.get("font") {
|
||||
None | Some(Value::Null) => f0,
|
||||
Some(Value::String(f)) if f.trim().is_empty() => None,
|
||||
Some(Value::String(f)) => Some(f.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
|
||||
};
|
||||
// An unknown font or an unreadable file is an error now, not a lost link later.
|
||||
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(LinkSource::Text { text, at, height, angle, font })
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -133,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => 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),
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
|
||||
_ => 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 = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
|
||||
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), 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));
|
||||
|
||||
@@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if let Geom2::Text { text, at, height, angle } = t {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() {
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
|
||||
{
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
n_text += 1;
|
||||
|
||||
@@ -495,6 +495,47 @@ fn text_embossed_or_debossed_leaves_its_counters_alone() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
|
||||
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
|
||||
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
|
||||
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
|
||||
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
|
||||
let near = |s: &Session, want: f64| {
|
||||
let a = profiles(s);
|
||||
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
|
||||
};
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
|
||||
near(&s, 13.5557);
|
||||
let link = r["link"].as_str().unwrap().to_string();
|
||||
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
|
||||
near(&s, 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
|
||||
near(&s, 4.0 * 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
|
||||
near(&s, 4.0 * 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// The font is kept in the design: after saving and reopening, the text is still in it.
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
|
||||
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
|
||||
// A new text takes the font too. Unknown fonts and values that are not names are errors.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
|
||||
near(&s, 17.6385);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_curve_joins_tangentially() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
|
||||
let text = sk
|
||||
.add_link(
|
||||
LinkKind::Text,
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
|
||||
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Font choice for sketch text. A small TrueType font is built here, so the outlines it must give are
|
||||
//! known by hand; the repository's second font (BIZ UDPGothic, `apps/solidcraft-web/fonts`) stands for
|
||||
//! a real one.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::fonts::find_family_in;
|
||||
use crate::tests::v;
|
||||
use crate::*;
|
||||
|
||||
/// One glyph of the test font: its character, advance width and contours (font units, y up; an outer
|
||||
/// contour runs clockwise, a counter counter-clockwise, as in TrueType).
|
||||
struct Glyph {
|
||||
ch: char,
|
||||
advance: u16,
|
||||
contours: Vec<Vec<(i16, i16)>>,
|
||||
}
|
||||
|
||||
fn block(x0: i16, y0: i16, x1: i16, y1: i16) -> Vec<(i16, i16)> {
|
||||
vec![(x0, y0), (x0, y1), (x1, y1), (x1, y0)]
|
||||
}
|
||||
|
||||
fn hole(x0: i16, y0: i16, x1: i16, y1: i16) -> Vec<(i16, i16)> {
|
||||
vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
||||
}
|
||||
|
||||
/// A TrueType font of 1000 units per em with these glyphs (and an empty `.notdef`), named `family`. It has
|
||||
/// no OS/2 table, so it has no capital height of its own.
|
||||
fn build_font(family: &str, glyphs: &[Glyph]) -> Vec<u8> {
|
||||
fn u16s(out: &mut Vec<u8>, values: &[u16]) {
|
||||
values.iter().for_each(|v| out.extend(v.to_be_bytes()));
|
||||
}
|
||||
fn u32s(out: &mut Vec<u8>, values: &[u32]) {
|
||||
values.iter().for_each(|v| out.extend(v.to_be_bytes()));
|
||||
}
|
||||
let count = glyphs.len() as u16 + 1;
|
||||
// glyf and loca (long offsets).
|
||||
let (mut glyf, mut loca) = (Vec::new(), vec![0u32]);
|
||||
for g in std::iter::once(None).chain(glyphs.iter().map(Some)) {
|
||||
if let Some(g) = g {
|
||||
let pts: Vec<(i16, i16)> = g.contours.iter().flatten().copied().collect();
|
||||
let (xs, ys): (Vec<i16>, Vec<i16>) = pts.iter().copied().unzip();
|
||||
let (x0, x1, y0, y1) = (xs.iter().min(), xs.iter().max(), ys.iter().min(), ys.iter().max());
|
||||
u16s(&mut glyf, &[g.contours.len() as u16]);
|
||||
glyf.extend([x0, y0, x1, y1].map(|v| v.copied().unwrap_or(0)).iter().flat_map(|v| v.to_be_bytes()));
|
||||
let mut end = 0;
|
||||
for c in &g.contours {
|
||||
end += c.len() as u16;
|
||||
u16s(&mut glyf, &[end - 1]);
|
||||
}
|
||||
u16s(&mut glyf, &[0]); // no instructions
|
||||
glyf.extend(vec![1u8; pts.len()]); // on-curve points, 16-bit deltas
|
||||
for coords in [xs, ys] {
|
||||
let mut prev = 0;
|
||||
for v in coords {
|
||||
glyf.extend((v - prev).to_be_bytes());
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
while glyf.len() % 4 != 0 {
|
||||
glyf.push(0);
|
||||
}
|
||||
}
|
||||
loca.push(glyf.len() as u32);
|
||||
}
|
||||
let mut head = Vec::new();
|
||||
u32s(&mut head, &[0x0001_0000, 0x0001_0000, 0, 0x5F0F_3CF5]);
|
||||
u16s(&mut head, &[0, 1000]);
|
||||
head.extend([0u8; 16]); // created, modified
|
||||
u16s(&mut head, &[0, 0, 1000, 1000, 0, 8, 2, 1, 0]); // box, style, smallest size, direction, long loca, format
|
||||
let mut hhea = Vec::new();
|
||||
u32s(&mut hhea, &[0x0001_0000]);
|
||||
u16s(&mut hhea, &[800, (-200i16) as u16, 0, 1000, 0, 0, 1000, 1, 0, 0, 0, 0, 0, 0, 0, count]);
|
||||
let mut maxp = Vec::new();
|
||||
u32s(&mut maxp, &[0x0001_0000]);
|
||||
u16s(&mut maxp, &[count]);
|
||||
let mut hmtx = Vec::new();
|
||||
u16s(&mut hmtx, &[0, 0]);
|
||||
for g in glyphs {
|
||||
u16s(&mut hmtx, &[g.advance, 0]);
|
||||
}
|
||||
// cmap: one format 12 subtable, a group per character.
|
||||
let mut chars: Vec<(u32, u32)> = glyphs.iter().enumerate().map(|(i, g)| (g.ch as u32, i as u32 + 1)).collect();
|
||||
chars.sort();
|
||||
let mut cmap = Vec::new();
|
||||
u16s(&mut cmap, &[0, 1, 3, 10]);
|
||||
u32s(&mut cmap, &[12, 0x000C_0000, 16 + 12 * chars.len() as u32, 0, chars.len() as u32]);
|
||||
for (c, g) in chars {
|
||||
u32s(&mut cmap, &[c, c, g]);
|
||||
}
|
||||
// name: family (1), style (2), full name (4), all Windows Unicode.
|
||||
let strings = [(1u16, family.to_string()), (2, "Regular".to_string()), (4, format!("{family} Regular"))];
|
||||
let (mut name, mut storage) = (Vec::new(), Vec::new());
|
||||
u16s(&mut name, &[0, strings.len() as u16, 6 + 12 * strings.len() as u16]);
|
||||
for (id, s) in &strings {
|
||||
let utf16: Vec<u16> = s.encode_utf16().collect();
|
||||
u16s(&mut name, &[3, 1, 0x409, *id, 2 * utf16.len() as u16, storage.len() as u16]);
|
||||
utf16.iter().for_each(|u| storage.extend(u.to_be_bytes()));
|
||||
}
|
||||
name.extend(storage);
|
||||
// The table directory, tables sorted by tag.
|
||||
let tables: [(&[u8; 4], Vec<u8>); 8] = [
|
||||
(b"cmap", cmap),
|
||||
(b"glyf", glyf),
|
||||
(b"head", head),
|
||||
(b"hhea", hhea),
|
||||
(b"hmtx", hmtx),
|
||||
(b"loca", loca.iter().flat_map(|o| o.to_be_bytes()).collect()),
|
||||
(b"maxp", maxp),
|
||||
(b"name", name),
|
||||
];
|
||||
let (mut dir, mut body) = (Vec::new(), Vec::new());
|
||||
u32s(&mut dir, &[0x0001_0000]);
|
||||
u16s(&mut dir, &[tables.len() as u16, 128, 3, 0]);
|
||||
for (tag, data) in &tables {
|
||||
dir.extend(*tag);
|
||||
u32s(&mut dir, &[0, 12 + 16 * tables.len() as u32 + body.len() as u32, data.len() as u32]);
|
||||
body.extend(data);
|
||||
while body.len() % 4 != 0 {
|
||||
body.push(0);
|
||||
}
|
||||
}
|
||||
dir.extend(body);
|
||||
dir
|
||||
}
|
||||
|
||||
/// The test font "Tiny Block": H is a 600 x 700 block, I a 100 x 700 bar and O an 800 x 700 block with a
|
||||
/// 400 x 300 counter at (200, 200), all in font units.
|
||||
fn tiny_font() -> Vec<u8> {
|
||||
build_font(
|
||||
"Tiny Block",
|
||||
&[
|
||||
Glyph { ch: 'H', advance: 700, contours: vec![block(0, 0, 600, 700)] },
|
||||
Glyph { ch: 'I', advance: 200, contours: vec![block(0, 0, 100, 700)] },
|
||||
Glyph { ch: 'O', advance: 900, contours: vec![block(0, 0, 800, 700), hole(200, 200, 600, 500)] },
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A folder of the test font and a few things that are not fonts, removed at the end of the test.
|
||||
struct Folder(PathBuf);
|
||||
|
||||
impl Folder {
|
||||
fn new(name: &str) -> Folder {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-font-tests-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("nested")).unwrap();
|
||||
std::fs::write(dir.join("nested").join("tiny.ttf"), tiny_font()).unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), "not a font").unwrap();
|
||||
std::fs::write(dir.join("broken.ttf"), "not a font either").unwrap();
|
||||
Folder(dir)
|
||||
}
|
||||
fn font(&self) -> String {
|
||||
self.0.join("nested").join("tiny.ttf").to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Folder {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn web_fonts() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("apps").join("solidcraft-web").join("fonts")
|
||||
}
|
||||
|
||||
/// The corner points of the lines of `geometry`, as (min, max) over x and y.
|
||||
fn extent(geometry: &[LinkGeom]) -> (Vec2, Vec2) {
|
||||
let pts: Vec<Vec2> = geometry
|
||||
.iter()
|
||||
.flat_map(|g| match g {
|
||||
LinkGeom::Line(a, b) => vec![*a, *b],
|
||||
other => panic!("only lines here: {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
|
||||
let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_file_sets_the_outlines_and_the_scale() {
|
||||
let folder = Folder::new("outlines");
|
||||
// The capital height is that of the font's H (700 units, no OS/2 table), so 7 mm tall text is 0.01 mm per unit:
|
||||
// the O is an 8 x 7 mm block (x 10 to 18, y 20 to 27) with a counter from (12, 22) to (16, 25).
|
||||
let o = text_geometry_in(Some(&folder.font()), "O", v(10.0, 20.0), 7.0, 0.0).unwrap();
|
||||
assert_eq!(o.len(), 8);
|
||||
let (lo, hi) = extent(&o);
|
||||
assert!((lo.x - 10.0).abs() < 1e-9 && (lo.y - 20.0).abs() < 1e-9 && (hi.x - 18.0).abs() < 1e-9 && (hi.y - 27.0).abs() < 1e-9, "{lo:?} {hi:?}");
|
||||
let inner = o.iter().filter(|g| matches!(g, LinkGeom::Line(a, _) if a.x > 10.5 && a.x < 17.5 && a.y > 20.5 && a.y < 26.5)).count();
|
||||
assert_eq!(inner, 4, "{o:?}");
|
||||
// The next glyph starts one advance later: an I after the 700 units of an H is 7 mm to the right.
|
||||
let hi_text = text_geometry_in(Some(&folder.font()), "HI", Vec2::ZERO, 7.0, 0.0).unwrap();
|
||||
let (lo, hi) = extent(&hi_text[4..]);
|
||||
assert!((lo.x - 7.0).abs() < 1e-9 && (hi.x - 8.0).abs() < 1e-9 && (hi.y - 7.0).abs() < 1e-9, "{lo:?} {hi:?}");
|
||||
// Without a font name the built-in one is used: its O is not a block.
|
||||
let builtin = text_geometry_in(None, "O", v(10.0, 20.0), 7.0, 0.0).unwrap();
|
||||
assert!(builtin.iter().all(|g| !matches!(g, LinkGeom::Line(..))));
|
||||
assert_eq!(builtin, text_geometry("O", v(10.0, 20.0), 7.0, 0.0).unwrap());
|
||||
// Turned by 90 degrees about its start, the 8 x 7 mm block stands 7 mm wide and 8 mm tall, left of the start.
|
||||
let turned = text_geometry_in(Some(&folder.font()), "O", Vec2::ZERO, 7.0, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
let (lo, hi) = extent(&turned);
|
||||
assert!((lo.x + 7.0).abs() < 1e-9 && hi.x.abs() < 1e-9 && lo.y.abs() < 1e-9 && (hi.y - 8.0).abs() < 1e-9, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_without_a_capital_height_or_an_h_uses_seven_tenths_of_the_em() {
|
||||
let font = build_font("Bar", &[Glyph { ch: 'I', advance: 200, contours: vec![block(0, 0, 100, 700)] }]);
|
||||
let folder = Folder::new("no-h");
|
||||
let path = folder.0.join("bar.ttf");
|
||||
std::fs::write(&path, font).unwrap();
|
||||
// 0.7 em = 700 units: the 100 x 700 bar at a capital height of 14 mm is 2 x 14 mm.
|
||||
let bar = text_geometry_in(Some(&path.to_string_lossy()), "I", Vec2::ZERO, 14.0, 0.0).unwrap();
|
||||
let (lo, hi) = extent(&bar);
|
||||
assert!(lo.x.abs() < 1e-9 && lo.y.abs() < 1e-9 && (hi.x - 2.0).abs() < 1e-9 && (hi.y - 14.0).abs() < 1e-9, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_font_closes_into_profiles_with_counters() {
|
||||
let folder = Folder::new("profiles");
|
||||
let (at, height) = (v(10.0, 20.0), 7.0);
|
||||
let font = folder.font();
|
||||
let source = LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()) };
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_link(LinkKind::Text, source, &text_geometry_in(Some(&font), "O", at, height, 0.0).unwrap()).unwrap();
|
||||
// The block with the counter in it is 8 x 7 mm minus 4 x 3 mm; the counter is a region of its own.
|
||||
let mut areas: Vec<f64> = find_profiles(&sk).iter().map(|p| p.area).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert_eq!(areas.len(), 2, "{areas:?}");
|
||||
assert!((areas[0] - 12.0).abs() < 1e-9 && (areas[1] - 44.0).abs() < 1e-9, "{areas:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_resolve_by_file_by_installed_name_or_to_the_built_in_one() {
|
||||
let folder = Folder::new("names");
|
||||
// No name, an empty one or the built-in family in any spelling: the built-in font.
|
||||
for name in [None, Some(""), Some(" "), Some("Liberation Sans"), Some("liberation-sans")] {
|
||||
assert_eq!(font_data(name).unwrap().bytes(), font_data(None).unwrap().bytes(), "{name:?}");
|
||||
}
|
||||
// A file by its path; its bytes are those of the file.
|
||||
let by_file = font_data(Some(&folder.font())).unwrap();
|
||||
assert_eq!(by_file.bytes(), tiny_font().as_slice());
|
||||
// A family by name in a font folder (searched below it, other files skipped), in any spelling; also by its full name.
|
||||
for name in ["Tiny Block", "tiny_block", "TINYBLOCK", "tiny-block", "Tiny Block Regular"] {
|
||||
let found = find_family_in(std::slice::from_ref(&folder.0), name).unwrap().unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(found.bytes(), tiny_font().as_slice(), "{name}");
|
||||
}
|
||||
assert!(find_family_in(std::slice::from_ref(&folder.0), "No Such Family").unwrap().is_none());
|
||||
assert!(find_family_in(&[folder.0.join("missing")], "Tiny Block").unwrap().is_none());
|
||||
// The second font of the repository, by its family name.
|
||||
let bundled = find_family_in(&[web_fonts()], "BIZ UDPGothic").unwrap().unwrap();
|
||||
assert_eq!(bundled.bytes(), std::fs::read(web_fonts().join("BIZUDPGothic-Regular.ttf")).unwrap().as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fonts_and_unreadable_files_are_errors() {
|
||||
let folder = Folder::new("errors");
|
||||
let message = |name: &str| font_data(Some(name)).err().unwrap().to_string();
|
||||
assert!(message("No Such Family 12345").contains("unknown font `No Such Family 12345`"));
|
||||
assert!(message("C:/no/such/dir/font.ttf").contains("font file `C:/no/such/dir/font.ttf`"));
|
||||
assert!(message(&folder.0.join("notes.txt").to_string_lossy()).contains("font file"));
|
||||
assert!(message(&folder.0.join("broken.ttf").to_string_lossy()).contains("is not a TrueType/OpenType font"));
|
||||
assert!(message(&"x".repeat(2000)).contains("too long"));
|
||||
// The error reaches text_geometry_in, which makes no geometry from it.
|
||||
assert!(text_geometry_in(Some("No Such Family 12345"), "A", Vec2::ZERO, 8.0, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_name_is_a_path_when_it_has_a_separator_or_a_font_extension() {
|
||||
let message = |name: &str| font_data(Some(name)).err().unwrap().to_string();
|
||||
// A name with a separator or a font extension is a file, whatever else it looks like; `%VAR%` is expanded in it and a
|
||||
// variable that is not set (or no variable at all) stays as written.
|
||||
assert!(message("%NO_SUCH_VAR_SOLIDCRAFT%/a.ttf").contains("font file `%NO_SUCH_VAR_SOLIDCRAFT%/a.ttf`"));
|
||||
assert!(message("100% sure.ttf").contains("font file `100% sure.ttf`"));
|
||||
assert!(message("Arial.TTF").contains("font file `Arial.TTF`"));
|
||||
assert!(message("fonts/a.otf").contains("font file `fonts/a.otf`"));
|
||||
// Anything else is a family name.
|
||||
assert!(message("No Such Family 12345").contains("unknown font"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_that_the_font_makes_too_large_is_refused() {
|
||||
// A glyph of 300 corner points, 20 000 lines at most in a link: 66 of them are 19 800 lines, 67 are 20 100.
|
||||
let ring: Vec<(i16, i16)> =
|
||||
(0..300).map(|i| ((500.0 * (f64::from(i) * 0.0209).cos()) as i16, (500.0 * (f64::from(i) * 0.0209).sin()) as i16)).collect();
|
||||
let font = build_font("Many", &[Glyph { ch: 'H', advance: 100, contours: vec![ring] }]);
|
||||
let folder = Folder::new("large");
|
||||
let path = folder.0.join("many.ttf").to_string_lossy().into_owned();
|
||||
std::fs::write(&path, font).unwrap();
|
||||
assert_eq!(text_geometry_in(Some(&path), &"H".repeat(66), Vec2::ZERO, 5.0, 0.0).unwrap().len(), 66 * 300);
|
||||
assert!(matches!(text_geometry_in(Some(&path), &"H".repeat(67), Vec2::ZERO, 5.0, 0.0), Err(SketchError::TooLarge)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() {
|
||||
let source = |font: Option<&str>| LinkSource::Text { text: "I".into(), at: Vec2::ZERO, height: 5.0, angle: 0.0, font: font.map(str::to_string) };
|
||||
// No font: nothing is written, so the text of a design reads and writes as it did before fonts, and a design saved
|
||||
// before them reads as text in the built-in font.
|
||||
let plain = serde_json::to_string(&source(None)).unwrap();
|
||||
assert!(!plain.contains("font"), "{plain}");
|
||||
assert_eq!(serde_json::from_str::<LinkSource>(&plain).unwrap(), source(None));
|
||||
// A font name or path comes back as it was written.
|
||||
let json = serde_json::to_string(&source(Some("C:/fonts/Some Font.ttf"))).unwrap();
|
||||
assert!(json.contains(r#""font":"C:/fonts/Some Font.ttf""#), "{json}");
|
||||
assert_eq!(serde_json::from_str::<LinkSource>(&json).unwrap(), source(Some("C:/fonts/Some Font.ttf")));
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Fonts for sketch text: the embedded default (Liberation Sans), a TrueType/OpenType file
|
||||
//! named by its path, or an installed font named by its family or full name, looked up in the
|
||||
//! system's and the user's font folders (plus `SOLIDCRAFT_FONT_DIRS`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::model::{Result, SketchError};
|
||||
|
||||
static DEFAULT_FONT: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
|
||||
|
||||
/// Family name of the built-in font (also what an empty or absent font name means).
|
||||
pub const DEFAULT_FONT_FAMILY: &str = "Liberation Sans";
|
||||
/// Largest font file read.
|
||||
const MAX_FONT_BYTES: u64 = 64 << 20;
|
||||
/// Most font files looked at when searching the font folders.
|
||||
const MAX_FONT_FILES: usize = 20_000;
|
||||
/// Deepest folder level searched below a font folder.
|
||||
const MAX_DIR_DEPTH: usize = 6;
|
||||
/// Font files kept in memory once read, and the most bytes they may add up to.
|
||||
const MAX_CACHED_FONTS: usize = 16;
|
||||
const MAX_CACHED_BYTES: usize = 128 << 20;
|
||||
/// Longest font name or path accepted.
|
||||
const MAX_FONT_NAME: usize = 1024;
|
||||
|
||||
/// The bytes of a font file and the face to use in it (collections hold several faces).
|
||||
#[derive(Clone)]
|
||||
pub struct FontData {
|
||||
bytes: FontBytes,
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Builtin,
|
||||
File(Arc<Vec<u8>>),
|
||||
}
|
||||
|
||||
impl FontData {
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
match &self.bytes {
|
||||
FontBytes::Builtin => DEFAULT_FONT,
|
||||
FontBytes::File(b) => b.as_slice(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The font a text names: `None`, an empty name or the built-in family give the built-in
|
||||
/// font; a name with a path separator or a font file extension is a file (`~` and `%VAR%` are
|
||||
/// expanded); anything else is the family (or full) name of an installed font.
|
||||
pub fn font_data(name: Option<&str>) -> Result<FontData> {
|
||||
let name = name.map(str::trim).unwrap_or("");
|
||||
if name.is_empty() || normalize(name) == normalize(DEFAULT_FONT_FAMILY) {
|
||||
return Ok(FontData { bytes: FontBytes::Builtin, index: 0 });
|
||||
}
|
||||
if name.len() > MAX_FONT_NAME {
|
||||
return Err(SketchError::Invalid("the font name is too long".into()));
|
||||
}
|
||||
if is_path(name) {
|
||||
let path = expand(name);
|
||||
let bytes = read_font(&path).map_err(|e| SketchError::Invalid(format!("font file `{name}`: {e}")))?;
|
||||
let index = 0;
|
||||
ttf_parser::Face::parse(&bytes, index)
|
||||
.map_err(|e| SketchError::Invalid(format!("font file `{name}` is not a TrueType/OpenType font: {e}")))?;
|
||||
return Ok(FontData { bytes: FontBytes::File(bytes), index });
|
||||
}
|
||||
find_family_in(&font_dirs(), name)?
|
||||
.ok_or_else(|| SketchError::Invalid(format!("unknown font `{name}`: not an installed font family, and not a .ttf/.otf file path")))
|
||||
}
|
||||
|
||||
/// An installed face whose family, full or PostScript name is `name`, searched in `dirs`.
|
||||
pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result<Option<FontData>> {
|
||||
let want = normalize(name);
|
||||
if want.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let files = font_files(dirs);
|
||||
// Files whose name resembles the family come first (arial.ttf for Arial, times.ttf for
|
||||
// Times New Roman): most lookups end there without opening every installed font.
|
||||
let (likely, rest): (Vec<&PathBuf>, Vec<&PathBuf>) = files.iter().partition(|p| {
|
||||
let stem = p.file_stem().map(|s| normalize(&s.to_string_lossy())).unwrap_or_default();
|
||||
stem.len() >= 3 && (stem.contains(&want) || want.contains(&stem))
|
||||
});
|
||||
for group in [likely, rest] {
|
||||
let mut best: Option<(u32, PathBuf, u32)> = None;
|
||||
for path in group {
|
||||
for face in face_names(path).iter() {
|
||||
let score = face.score(&want);
|
||||
if score > 0 && best.as_ref().is_none_or(|(b, _, _)| score > *b) {
|
||||
best = Some((score, path.clone(), face.index));
|
||||
}
|
||||
}
|
||||
if best.as_ref().is_some_and(|(s, _, _)| *s >= 4) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((_, path, index)) = best {
|
||||
let bytes = read_font(&path).map_err(|e| SketchError::Invalid(format!("font file `{}`: {e}", path.display())))?;
|
||||
return Ok(Some(FontData { bytes: FontBytes::File(bytes), index }));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The names of one face of a font file (normalised), for matching.
|
||||
struct FaceNames {
|
||||
index: u32,
|
||||
regular: bool,
|
||||
names: Vec<(u16, String)>,
|
||||
}
|
||||
|
||||
impl FaceNames {
|
||||
/// How well the face answers to a (normalised) name: 4 full or PostScript name, 3 family
|
||||
/// name of a regular face, 2 family name of another style, 0 no match.
|
||||
fn score(&self, want: &str) -> u32 {
|
||||
use ttf_parser::name_id;
|
||||
self.names
|
||||
.iter()
|
||||
.filter(|(_, n)| n == want)
|
||||
.map(|(id, _)| match *id {
|
||||
name_id::FULL_NAME | name_id::POST_SCRIPT_NAME => 4,
|
||||
name_id::FAMILY | name_id::TYPOGRAPHIC_FAMILY if self.regular => 3,
|
||||
name_id::FAMILY | name_id::TYPOGRAPHIC_FAMILY => 2,
|
||||
_ => 0,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces' names of a font file, read once per process (a lookup that finds nothing reads
|
||||
/// every installed font once, not on every try).
|
||||
fn face_names(path: &Path) -> Arc<Vec<FaceNames>> {
|
||||
static NAMES: OnceLock<Mutex<HashMap<PathBuf, Arc<Vec<FaceNames>>>>> = OnceLock::new();
|
||||
let cache = NAMES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Ok(c) = cache.lock()
|
||||
&& let Some(n) = c.get(path)
|
||||
{
|
||||
return n.clone();
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
if let Ok(bytes) = read_file(path) {
|
||||
let count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1).min(64);
|
||||
for index in 0..count {
|
||||
let Ok(face) = ttf_parser::Face::parse(&bytes, index) else { continue };
|
||||
let names = face
|
||||
.names()
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
let text = match n.to_string() {
|
||||
Some(t) => t,
|
||||
// Macintosh Roman names: their ASCII part.
|
||||
None if n.platform_id == ttf_parser::PlatformId::Macintosh => {
|
||||
n.name.iter().filter(|b| b.is_ascii()).map(|b| *b as char).collect()
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
Some((n.name_id, normalize(&text)))
|
||||
})
|
||||
.collect();
|
||||
faces.push(FaceNames { index, regular: face.is_regular() || (!face.is_bold() && !face.is_italic()), names });
|
||||
}
|
||||
}
|
||||
let faces = Arc::new(faces);
|
||||
if let Ok(mut c) = cache.lock()
|
||||
&& c.len() < MAX_FONT_FILES
|
||||
{
|
||||
c.insert(path.to_path_buf(), faces.clone());
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
/// Lower-case letters and digits only: "DejaVu_Sans", "Dejavu Sans" and "dejavu-sans" are one name.
|
||||
fn normalize(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
fn is_path(name: &str) -> bool {
|
||||
name.contains('/') || name.contains('\\') || has_font_extension(name)
|
||||
}
|
||||
|
||||
fn has_font_extension(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
[".ttf", ".otf", ".ttc", ".otc"].iter().any(|e| lower.ends_with(e))
|
||||
}
|
||||
|
||||
/// `~` at the start and `%VAR%` replaced from the environment.
|
||||
fn expand(name: &str) -> PathBuf {
|
||||
let mut s = name.to_string();
|
||||
if let Some(rest) = s.strip_prefix('~')
|
||||
&& (rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\'))
|
||||
&& let Some(home) = home()
|
||||
{
|
||||
s = format!("{}{rest}", home.display());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some((head, tail)) = rest.split_once('%') {
|
||||
out.push_str(head);
|
||||
match tail.split_once('%') {
|
||||
// A variable that is not set stays as written.
|
||||
Some((var, after)) if !var.is_empty() && !var.contains(['=', '\0']) => {
|
||||
match std::env::var(var) {
|
||||
Ok(v) => out.push_str(&v),
|
||||
Err(_) => {
|
||||
out.push('%');
|
||||
out.push_str(var);
|
||||
out.push('%');
|
||||
}
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
PathBuf::from(out)
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// The folders searched for installed fonts, in order.
|
||||
pub fn font_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = std::env::var_os("SOLIDCRAFT_FONT_DIRS").map(|v| std::env::split_paths(&v).collect()).unwrap_or_default();
|
||||
let env = |k: &str| std::env::var_os(k).map(PathBuf::from);
|
||||
if cfg!(windows) {
|
||||
dirs.push(env("WINDIR").or_else(|| env("SystemRoot")).unwrap_or_else(|| PathBuf::from("C:\\Windows")).join("Fonts"));
|
||||
if let Some(local) = env("LOCALAPPDATA") {
|
||||
dirs.push(local.join("Microsoft").join("Windows").join("Fonts"));
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
dirs.push(PathBuf::from("/System/Library/Fonts"));
|
||||
dirs.push(PathBuf::from("/Library/Fonts"));
|
||||
if let Some(h) = home() {
|
||||
dirs.push(h.join("Library").join("Fonts"));
|
||||
}
|
||||
} else {
|
||||
if let Some(d) = env("XDG_DATA_HOME") {
|
||||
dirs.push(d.join("fonts"));
|
||||
}
|
||||
if let Some(h) = home() {
|
||||
dirs.push(h.join(".local").join("share").join("fonts"));
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/usr/local/share/fonts"));
|
||||
dirs.push(PathBuf::from("/usr/share/fonts"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// TrueType/OpenType files under `dirs` (bounded in depth and count).
|
||||
fn font_files(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut todo: Vec<(PathBuf, usize)> = dirs.iter().rev().map(|d| (d.clone(), 0)).collect();
|
||||
while let Some((dir, depth)) = todo.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
let mut sub = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let Ok(kind) = e.file_type() else { continue };
|
||||
if kind.is_dir() {
|
||||
if depth < MAX_DIR_DEPTH {
|
||||
sub.push((path, depth + 1));
|
||||
}
|
||||
} else if has_font_extension(&path.to_string_lossy()) {
|
||||
out.push(path);
|
||||
if out.len() >= MAX_FONT_FILES {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
sub.sort();
|
||||
todo.extend(sub.into_iter().rev());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A font file's bytes (bounded in size).
|
||||
fn read_file(path: &Path) -> std::result::Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() {
|
||||
return Err("not a file".into());
|
||||
}
|
||||
if meta.len() > MAX_FONT_BYTES {
|
||||
return Err("the file is too large for a font".into());
|
||||
}
|
||||
std::fs::read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A font file's bytes, from the cache when it was read before.
|
||||
fn read_font(path: &Path) -> std::result::Result<Arc<Vec<u8>>, String> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<Vec<u8>>>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Ok(c) = cache.lock()
|
||||
&& let Some(b) = c.get(path)
|
||||
{
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let bytes = Arc::new(read_file(path)?);
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if c.len() >= MAX_CACHED_FONTS || c.values().map(|b| b.len()).sum::<usize>() + bytes.len() > MAX_CACHED_BYTES {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(path.to_path_buf(), bytes.clone());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod construct;
|
||||
mod curves;
|
||||
mod decompose;
|
||||
mod dims;
|
||||
mod fonts;
|
||||
mod freeform_offset;
|
||||
mod linalg;
|
||||
mod link;
|
||||
@@ -41,6 +42,7 @@ pub use curves::{
|
||||
};
|
||||
pub use decompose::Budget;
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, font_data};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};
|
||||
@@ -55,7 +57,7 @@ pub use shape::{Shape, intersections};
|
||||
pub use solver::{
|
||||
Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with,
|
||||
};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry, text_geometry_in};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -65,6 +67,8 @@ mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod continuation_tests;
|
||||
#[cfg(test)]
|
||||
mod font_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -71,13 +71,16 @@ pub enum LinkSource {
|
||||
Iso { body: String, at: Vec3, dir: String },
|
||||
/// The outline of a body spun about an axis (world line), laid into the sketch plane.
|
||||
Spun { body: String, origin: Vec3, dir: Vec3 },
|
||||
/// Text: baseline start `at` (sketch coordinates), capital height and angle (radians).
|
||||
/// Text: baseline start `at` (sketch coordinates), capital height and angle (radians), in
|
||||
/// `font` (a font file path or an installed family name; none: the built-in font).
|
||||
Text {
|
||||
text: String,
|
||||
at: Vec2,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
angle: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
font: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ fn merging_adjacent_regions() {
|
||||
/// 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 };
|
||||
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None };
|
||||
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
|
||||
sk
|
||||
}
|
||||
|
||||
+73
-18
@@ -1,14 +1,17 @@
|
||||
//! 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.
|
||||
//! Sketch text: glyph outlines of a font as lines, conics and cubic splines, so text closes
|
||||
//! into profiles that can be extruded. TrueType quadratic Béziers are conics with rho = 0.5;
|
||||
//! OpenType (CFF) cubic Béziers are cubic control splines, both exact. A glyph's counters (the
|
||||
//! holes of A, B, 8…) are loops inside its outline; profile detection makes them holes.
|
||||
//!
|
||||
//! The default font is embedded (Liberation Sans, SIL OFL 1.1; see `fonts/OFL.txt` and
|
||||
//! ATTRIBUTION.md); [`crate::fonts`] finds others by file path or installed family name.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::link::LinkGeom;
|
||||
use crate::fonts::font_data;
|
||||
use crate::link::{LinkGeom, MAX_LINK_ENTITIES};
|
||||
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;
|
||||
|
||||
@@ -37,6 +40,20 @@ impl Outline {
|
||||
self.out.push(LinkGeom::Conic { a, apex, b, rho: 0.5 });
|
||||
self.cur = to;
|
||||
}
|
||||
fn cubic(&mut self, c1: Vec2, c2: Vec2, to: Vec2) {
|
||||
let (a, b) = (self.cur, to);
|
||||
let chord = b - a;
|
||||
let l = chord.len().max(1e-12);
|
||||
let off = (chord.cross(c1 - a).abs() / l).max(chord.cross(c2 - a).abs() / l);
|
||||
// Straight (controls on the chord, between its ends): a line.
|
||||
let between = |c: Vec2| (c - a).dot(chord) >= -1e-9 && (c - b).dot(chord) <= 1e-9;
|
||||
if a.dist(b) < 1e-9 || (off < 1e-6 && between(c1) && between(c2)) {
|
||||
self.line(to);
|
||||
return;
|
||||
}
|
||||
self.out.push(LinkGeom::Spline { pts: vec![a, c1, c2, b], control: true, degree: 3, periodic: false });
|
||||
self.cur = to;
|
||||
}
|
||||
}
|
||||
|
||||
impl ttf_parser::OutlineBuilder for Outline {
|
||||
@@ -54,13 +71,8 @@ impl ttf_parser::OutlineBuilder for Outline {
|
||||
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);
|
||||
let (c1, c2, p) = ((self.map)(x1, y1), (self.map)(x2, y2), (self.map)(x, y));
|
||||
self.cubic(c1, c2, p);
|
||||
}
|
||||
fn close(&mut self) {
|
||||
let s = self.start;
|
||||
@@ -68,19 +80,27 @@ impl ttf_parser::OutlineBuilder for Outline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline geometry of `text` with its baseline starting at `at`, capital height `height`
|
||||
/// (mm), rotated by `angle` (radians) about `at`. Lines break at `\n`.
|
||||
/// Outline geometry of `text` in the built-in font (see [`text_geometry_in`]).
|
||||
pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Vec<LinkGeom>> {
|
||||
text_geometry_in(None, text, at, height, angle)
|
||||
}
|
||||
|
||||
/// Outline geometry of `text` in `font` (None: the built-in font; else a font file path or an
|
||||
/// installed family name, see [`crate::fonts::font_data`]) with its baseline starting at `at`,
|
||||
/// capital height `height` (mm), rotated by `angle` (radians) about `at`. Lines break at `\n`.
|
||||
pub fn text_geometry_in(font: Option<&str>, 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 data = font_data(font)?;
|
||||
let face = ttf_parser::Face::parse(data.bytes(), data.index).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
|
||||
let cap = cap_height(&face);
|
||||
let k = height / cap;
|
||||
let line_gap = f64::from(face.ascender() - face.descender() + face.line_gap()) * k;
|
||||
let line_gap = f64::from(face.ascender()) - f64::from(face.descender()) + f64::from(face.line_gap());
|
||||
let line_gap = if line_gap > 0.0 { line_gap * k } else { height * 1.5 };
|
||||
let (s, c) = angle.sin_cos();
|
||||
let mut out = Vec::new();
|
||||
for (row, line) in text.split('\n').enumerate() {
|
||||
@@ -96,12 +116,26 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
|
||||
let mut ob = Outline { out: Vec::new(), start: at, cur: at, map };
|
||||
let _ = face.outline_glyph(gid, &mut ob);
|
||||
out.extend(ob.out);
|
||||
// A font can give a glyph any number of points: stop where the link would be refused anyway.
|
||||
if out.len() > MAX_LINK_ENTITIES {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
pen += f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The font's capital height in font units: from its OS/2 table, else the top of its `H`, else
|
||||
/// 70 % of the em.
|
||||
fn cap_height(face: &ttf_parser::Face) -> f64 {
|
||||
face.capital_height()
|
||||
.map(f64::from)
|
||||
.filter(|c| *c > 0.0)
|
||||
.or_else(|| face.glyph_index('H').and_then(|g| face.glyph_bounding_box(g)).map(|b| f64::from(b.y_max)).filter(|c| *c > 0.0))
|
||||
.unwrap_or(f64::from(face.units_per_em().max(1)) * 0.7)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -126,4 +160,25 @@ mod tests {
|
||||
// Empty text makes nothing (not an error here; the command rejects it).
|
||||
assert!(text_geometry("", Vec2::ZERO, 1.0, 0.0).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_file_gives_the_same_outlines_and_unknown_fonts_are_errors() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LiberationSans-Regular.ttf");
|
||||
let by_file = text_geometry_in(Some(&path.to_string_lossy()), "A7_L", Vec2::ZERO, 8.0, 0.0).unwrap();
|
||||
assert_eq!(by_file, text_geometry("A7_L", Vec2::ZERO, 8.0, 0.0).unwrap());
|
||||
let e = text_geometry_in(Some("No Such Family 12345"), "A", Vec2::ZERO, 8.0, 0.0).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown font"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_outlines_stay_exact_splines() {
|
||||
let mut ob = Outline { out: Vec::new(), start: Vec2::ZERO, cur: Vec2::ZERO, map: Box::new(|x, y| Vec2::new(f64::from(x), f64::from(y))) };
|
||||
ttf_parser::OutlineBuilder::move_to(&mut ob, 0.0, 0.0);
|
||||
ttf_parser::OutlineBuilder::curve_to(&mut ob, 0.0, 10.0, 10.0, 10.0, 10.0, 0.0);
|
||||
// A cubic with its controls on the chord is a line.
|
||||
ttf_parser::OutlineBuilder::curve_to(&mut ob, 7.0, 0.0, 3.0, 0.0, 0.0, 0.0);
|
||||
assert_eq!(ob.out.len(), 2);
|
||||
assert!(matches!(&ob.out[0], LinkGeom::Spline { pts, control: true, degree: 3, .. } if pts.len() == 4));
|
||||
assert!(matches!(ob.out[1], LinkGeom::Line(..)));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ pub struct Tx {
|
||||
pub(crate) link: String,
|
||||
pub(crate) text: String,
|
||||
initial_text: String,
|
||||
/// The font as typed: an installed font's family name or a font file's path; empty is the built-in font.
|
||||
pub(crate) font: String,
|
||||
initial_font: String,
|
||||
values: [String; 4],
|
||||
/// The fields as first shown; an untouched field keeps its exact saved value.
|
||||
initial: [String; 4],
|
||||
@@ -57,15 +60,16 @@ fn shown(value: f64, unit: &str) -> String {
|
||||
format!("{text} {unit}")
|
||||
}
|
||||
|
||||
/// The saved text source behind `link` in the active sketch.
|
||||
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<(String, [f64; 4])> {
|
||||
/// The saved text source behind `link` in the active sketch: its text, its font (empty: the built-in
|
||||
/// font), and x, y, height and angle.
|
||||
fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<(String, String, [f64; 4])> {
|
||||
if app.session.active_sketch != Some(sketch) {
|
||||
return None;
|
||||
}
|
||||
let sk = app.session.doc.sketch(sketch).ok()?;
|
||||
let source = sk.link(link).filter(|source| source.kind == LinkKind::Text)?;
|
||||
let LinkSource::Text { text, at, height, angle } = &source.source else { return None };
|
||||
Some((text.clone(), [at.x, at.y, *height, *angle]))
|
||||
let LinkSource::Text { text, at, height, angle, font } = &source.source else { return None };
|
||||
Some((text.clone(), font.clone().unwrap_or_default(), [at.x, at.y, *height, *angle]))
|
||||
}
|
||||
|
||||
fn fields(original: &[f64; 4], unit: &'static str, scale: f64) -> [String; 4] {
|
||||
@@ -80,10 +84,22 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
|
||||
let sketch = app.session.active_sketch?;
|
||||
let sk = app.session.doc.sketch(sketch).ok()?;
|
||||
let link = sk.curves.iter().find(|curve| curve.id == entity).and_then(|curve| curve.link.clone())?;
|
||||
let (text, original) = source(app, sketch, &link)?;
|
||||
let (text, font, original) = source(app, sketch, &link)?;
|
||||
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let values = fields(&original, unit, scale);
|
||||
Some(Kind::Text(Tx { sketch, link, initial_text: text.clone(), text, initial: values.clone(), values, original, unit, focus: true }))
|
||||
Some(Kind::Text(Tx {
|
||||
sketch,
|
||||
link,
|
||||
initial_text: text.clone(),
|
||||
text,
|
||||
initial_font: font.clone(),
|
||||
font,
|
||||
initial: values.clone(),
|
||||
values,
|
||||
original,
|
||||
unit,
|
||||
focus: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
|
||||
@@ -114,7 +130,7 @@ pub(crate) fn editing(app: &SolveApp) -> bool {
|
||||
/// text deleted or undone). Other design changes leave it open; when the saved source itself
|
||||
/// changed (undo, a script), fields the user has not touched follow it.
|
||||
pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
|
||||
let Some((text, original)) = source(app, k.sketch, &k.link) else { return false };
|
||||
let Some((text, font, original)) = source(app, k.sketch, &k.link) else { return false };
|
||||
if original != k.original {
|
||||
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let fresh = fields(&original, unit, scale);
|
||||
@@ -133,6 +149,12 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
|
||||
}
|
||||
k.initial_text = text;
|
||||
}
|
||||
if font != k.initial_font {
|
||||
if k.font == k.initial_font {
|
||||
k.font = font.clone();
|
||||
}
|
||||
k.initial_font = font;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -147,6 +169,12 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
|
||||
k.focus = false;
|
||||
}
|
||||
ui.end_row();
|
||||
crate::dialogs::row_label(ui, "Font");
|
||||
// Empty means the built-in font: its name is the hint.
|
||||
let hint = solidcraft_engine::sketch::DEFAULT_FONT_FAMILY;
|
||||
let response = ui.add(egui::TextEdit::singleline(&mut k.font).id_salt("sketch_text_font").hint_text(hint).desired_width(width));
|
||||
crate::scenario::publish_handle("dialog:Font", response.rect.center());
|
||||
ui.end_row();
|
||||
let mut enter = false;
|
||||
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
|
||||
crate::dialogs::row_label(ui, label);
|
||||
@@ -183,6 +211,10 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
|
||||
if k.values.get(3) != k.initial.get(3) {
|
||||
params["angle"] = json!(angle.to_degrees());
|
||||
}
|
||||
// An untouched font stays as saved; an emptied one is the built-in font.
|
||||
if k.font.trim() != k.initial_font.trim() {
|
||||
params["font"] = json!(k.font.trim());
|
||||
}
|
||||
Ok(vec![("sketch.edit_text".into(), params)])
|
||||
}
|
||||
|
||||
@@ -247,6 +279,7 @@ mod tests {
|
||||
at: solidcraft_engine::geom::Vec2::new(25.0, 12.0),
|
||||
height: 20.0,
|
||||
angle: std::f64::consts::FRAC_PI_2,
|
||||
font: None,
|
||||
};
|
||||
assert_eq!(sk.link(&link).unwrap().source, expected);
|
||||
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
|
||||
@@ -272,7 +305,10 @@ mod tests {
|
||||
replace(&mut h, "dialog:Text", "O");
|
||||
ok(&mut h);
|
||||
let LinkSource::Text { at, height, angle, .. } = original else { panic!("text source") };
|
||||
assert_eq!(h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source, LinkSource::Text { text: "O".into(), at, height, angle });
|
||||
assert_eq!(
|
||||
h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source,
|
||||
LinkSource::Text { text: "O".into(), at, height, angle, font: None }
|
||||
);
|
||||
h.app.session.selection = vec![Sel::SketchCurve { id: h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone() }];
|
||||
h.app.start("sketch.edit_text");
|
||||
h.frames(3);
|
||||
@@ -291,6 +327,64 @@ mod tests {
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_row_sets_keeps_and_clears_the_font_of_a_text() {
|
||||
// The repository's second font, BIZ UDPGothic: its "I" is 17.6385 mm² at a capital height of 10 mm, the built-in
|
||||
// font's 13.5557 mm².
|
||||
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
|
||||
let (mut h, link, _) = setup();
|
||||
// The dialog closes with the selection; the text's first outline is selected again to open it.
|
||||
let reopen = |h: &mut Harness| {
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let id = h.app.session.doc.sketch(sid).unwrap().curves[0].id.clone();
|
||||
h.app.session.selection = vec![Sel::SketchCurve { id }];
|
||||
h.app.start("sketch.edit_text");
|
||||
h.frames(3);
|
||||
};
|
||||
let area = |h: &Harness| -> f64 {
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
h.app.session.world_state().sketch(sid).unwrap().profiles.iter().map(|p| p.area).sum()
|
||||
};
|
||||
assert!((area(&h) - 13.5557).abs() < 2e-3);
|
||||
h.app.start("sketch.edit_text");
|
||||
h.frames(3);
|
||||
assert_eq!(tx(&h.app).font, "");
|
||||
// A font that is not there keeps the dialog open with the reason, and the design as it was.
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
replace(&mut h, "dialog:Font", "No Such Family 12345");
|
||||
ok(&mut h);
|
||||
assert!(editing(&h.app));
|
||||
assert!(h.app.dialog.as_ref().unwrap().error.as_ref().is_some_and(|e| e.contains("unknown font")));
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
|
||||
// A font file: one undo step, other outlines, and the dialog shows the font when it is opened again.
|
||||
replace(&mut h, "dialog:Font", font);
|
||||
ok(&mut h);
|
||||
assert!(!editing(&h.app));
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let font_of = |h: &Harness| match &h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source {
|
||||
LinkSource::Text { font, .. } => font.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(font_of(&h).as_deref(), Some(font));
|
||||
assert!((area(&h) - 17.6385).abs() < 2e-3);
|
||||
reopen(&mut h);
|
||||
assert_eq!(tx(&h.app).font, font);
|
||||
// Changing only the text keeps the font: two I's of it.
|
||||
replace(&mut h, "dialog:Text", "II");
|
||||
ok(&mut h);
|
||||
assert_eq!(font_of(&h).as_deref(), Some(font));
|
||||
assert!((area(&h) - 2.0 * 17.6385).abs() < 2e-3);
|
||||
// An emptied field is the built-in font again: two I's of it.
|
||||
reopen(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Font"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"key":"Backspace"})).unwrap();
|
||||
ok(&mut h);
|
||||
assert_eq!(font_of(&h), None);
|
||||
assert!((area(&h) - 2.0 * 13.5557).abs() < 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_text_values_and_cancel_leave_document_and_history_unchanged() {
|
||||
let (mut h, _, _) = setup();
|
||||
|
||||
@@ -51,7 +51,7 @@ keeps the current design. Previews cannot be edited or measured. This experiment
|
||||
requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 16, including sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
Current designs use format 16, including the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
links, persistent
|
||||
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||
|
||||
@@ -31,6 +31,12 @@ fit-point and control-point splines, conics, points and text. **Construction** (
|
||||
into reference geometry that doesn't form profiles. Modify tools include trim (T), extend, break,
|
||||
fillet, chamfer, offset (O), mirror, patterns, move/copy and scale.
|
||||
|
||||
**Text** is set in the built-in font (Liberation Sans) unless it is given another: `sketch.text` and
|
||||
`sketch.edit_text` take a `font`, the family or full name of an installed font ("DejaVu Sans") or the path
|
||||
of a `.ttf` / `.otf` file, and the **Edit Text** dialog has a **Font** field for it. An unknown font is an
|
||||
error, and the font is kept with the text in the design, so the text stays editable in it. `SOLIDCRAFT_FONT_DIRS`
|
||||
adds folders to the ones searched for installed fonts.
|
||||
|
||||
**Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that
|
||||
follows later changes. Sketching on a face projects its edges automatically.
|
||||
For Project, select visible source curves or points from another sketch, model edges,
|
||||
|
||||
Reference in new issue
Block a user