Sketch text: bold and italic, text along a curve, alignment and spacing (#852)
A sketch text gets an optional style: bold and italic faces (a slanted fallback for italic), left/center/right alignment, letter and line spacing, and a path curve of the same sketch for the baseline to follow (reverse takes the other side). Edit Text gets B/I toggles, alignment, spacing fields and a Path chip, all live. The style is saved only when not plain and is noted under format 18.
This commit is contained in:
1 parent
fe3717f2e4
commit
dd7790a43f
21 files changed
+1082
-100
No files matched your search
@@ -108,6 +108,11 @@
|
||||
//! bodies) and `ReverseNormal` (a surface body's faces turned over), each a `body` and
|
||||
//! optional `faces` points. Version 17 designs need no transformation; the version stamp makes
|
||||
//! 0.9.0 (which reads up to 17) ask for an update instead of meeting a feature it can't read.
|
||||
//! Under the same version, a sketch text's link source can carry a `style`: `bold` and
|
||||
//! `italic` (the font's bold or italic face), `align` (`left`, `center` or `right`),
|
||||
//! `letter_spacing` (mm), `line_spacing` (times the font's line distance), `path` (the id of a
|
||||
//! curve of the same sketch the baseline follows) and `reverse`. A text without `style` is set
|
||||
//! as it always was; 0.9.0 would drop the style and set the text plain, off its curve.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -255,7 +255,11 @@ 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, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
|
||||
LinkSource::Text { text, at, height, angle, font, style } if style.path.is_none() => {
|
||||
solidcraft_sketch::text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, None)?
|
||||
}
|
||||
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
|
||||
LinkSource::Text { .. } => return Err(DocError::Invalid("a text on a path is set with its sketch".into())),
|
||||
LinkSource::Plane { name } => {
|
||||
let pr =
|
||||
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
|
||||
@@ -586,7 +590,15 @@ fn refresh_links_in(
|
||||
}
|
||||
}
|
||||
}
|
||||
let resolved = if l.auto_face {
|
||||
let resolved = if matches!(source, LinkSource::Text { .. }) {
|
||||
// A text needs nothing outside its sketch; one on a path follows a curve of it.
|
||||
sk.text_source_geometry(&source).map_err(DocError::from).and_then(|geom| {
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} makes no outline", source.describe())));
|
||||
}
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: source.clone(), moved: false, face_index: None, edge_index: None })
|
||||
})
|
||||
} else if l.auto_face {
|
||||
anchor
|
||||
.filter(|anchor| {
|
||||
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextStyle};
|
||||
|
||||
/// The style parameters of `sketch.text` and `sketch.edit_text`.
|
||||
macro_rules! text_style_doc {
|
||||
() => {
|
||||
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text), reverse?: bool (the other way along the path)"
|
||||
};
|
||||
}
|
||||
|
||||
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
@@ -84,20 +91,62 @@ 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, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
|
||||
"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), ",
|
||||
text_style_doc!(),
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.live(edit_text_live)
|
||||
.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); live?: bool: one frame of an interactive resize or rotation (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)",
|
||||
"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), ",
|
||||
text_style_doc!(),
|
||||
" (each kept when left out); live?: bool: one frame of an interactive change (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)",
|
||||
created_doc!()
|
||||
)),
|
||||
];
|
||||
|
||||
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));
|
||||
/// The style a command's parameters give, over `old`.
|
||||
fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
|
||||
let mut style = old;
|
||||
for (key, slot) in [("bold", &mut style.bold), ("italic", &mut style.italic), ("reverse", &mut style.reverse)] {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Bool(b)) => *slot = *b,
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
match p.get("align") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(a)) => {
|
||||
style.align = match a.trim().to_ascii_lowercase().as_str() {
|
||||
"left" => TextAlign::Left,
|
||||
"center" | "centre" => TextAlign::Center,
|
||||
"right" => TextAlign::Right,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center or right")),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
|
||||
}
|
||||
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
|
||||
style.letter_spacing =
|
||||
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
|
||||
}
|
||||
if p.get("line_spacing").is_some_and(|v| !v.is_null()) {
|
||||
style.line_spacing =
|
||||
num(p, "line_spacing").filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad(cmd, "`line_spacing` must be a positive factor"))?;
|
||||
}
|
||||
match p.get("path") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(c)) if c.trim().is_empty() => style.path = None,
|
||||
Some(Value::String(c)) => style.path = Some(c.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`path` must be a curve id")),
|
||||
}
|
||||
Ok(style)
|
||||
}
|
||||
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>, TextStyle)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0, f0, s0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default()));
|
||||
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"));
|
||||
@@ -117,13 +166,14 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
|
||||
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")),
|
||||
};
|
||||
let style = text_style(p, cmd, s0)?;
|
||||
// A font file an agent names must lie in its folders (installed families are found by name).
|
||||
if let Some(path) = font.as_deref().and_then(solidcraft_sketch::font_file_path) {
|
||||
solidcraft_io::vfs::check(&path.to_string_lossy()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
// 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 })
|
||||
// An unknown font, an unreadable file or a style the font lacks is an error now, not a lost link later.
|
||||
solidcraft_sketch::font_data_styled(font.as_deref(), style.bold, style.italic).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(LinkSource::Text { text, at, height, angle, font, style })
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -135,7 +185,12 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)?;
|
||||
// A text needs nothing outside its sketch (a path is one of its curves).
|
||||
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if geom.is_empty() {
|
||||
return Err(bad(cmd, "the text makes no outline"));
|
||||
}
|
||||
let l = sk.add_link(LinkKind::Text, src, &geom)?;
|
||||
let curves = ids_of(&sk, &sk.link_curves(&l));
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
@@ -154,15 +209,18 @@ pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> {
|
||||
|
||||
/// Transform a text source as a unit; generated outlines remain linked and editable.
|
||||
pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result<Vec<usize>> {
|
||||
let Some(LinkSource::Text { text, at, height, angle, font }) = sk.link(link).map(|l| l.source.clone()) else {
|
||||
let Some(LinkSource::Text { text, at, height, angle, font, style }) = sk.link(link).map(|l| l.source.clone()) else {
|
||||
return Err(bad("sketch.move", "the selected source is not text"));
|
||||
};
|
||||
if style.path.is_some() {
|
||||
return Err(bad("sketch.move", "a text on a path follows its curve: move the curve, or take the text off it"));
|
||||
}
|
||||
let q = at - center;
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
|
||||
let angle = angle + rotation;
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), &text, at, height, angle)?;
|
||||
let source = LinkSource::Text { text, at, height, angle, font };
|
||||
let geom = solidcraft_sketch::text_geometry_styled(font.as_deref(), &text, at, height, angle, &style, None)?;
|
||||
let source = LinkSource::Text { text, at, height, angle, font, style };
|
||||
let link = if copy {
|
||||
sk.add_link(LinkKind::Text, source, &geom)?
|
||||
} else {
|
||||
@@ -205,12 +263,14 @@ fn apply_text_edit(sk: &mut Sketch, 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, font })) => (text, at, height, angle, font),
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font, style })) => (text, at, height, angle, font, style),
|
||||
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
|
||||
};
|
||||
let src = text_source(p, cmd, Some(old))?;
|
||||
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)?;
|
||||
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if geom.is_empty() {
|
||||
return Err(bad(cmd, "the text makes no outline"));
|
||||
}
|
||||
sk.set_link_source(&l, src.clone());
|
||||
sk.update_link(&l, &geom)?;
|
||||
let curves = ids_of(sk, &sk.link_curves(&l));
|
||||
|
||||
@@ -135,7 +135,15 @@ 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, font: None }).is_ok()
|
||||
if super::sketch_project::add_link(
|
||||
s,
|
||||
&doc,
|
||||
id,
|
||||
&mut sk,
|
||||
LinkKind::Text,
|
||||
LinkSource::Text { text, at, height, angle, font: None, style: Default::default() },
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
|
||||
@@ -20,7 +20,7 @@ fn text_move_updates_the_source_once_preserves_ids_and_roundtrips() {
|
||||
run(&mut s, "sketch.move", json!({"entities":[curve,ids[1]],"translate":[10,-2],"center":[3,4],"angle":30}));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
assert_eq!(sk.curves.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ids);
|
||||
let LinkSource::Text { at, angle, text, height, font } = &sk.link(&link).unwrap().source else { panic!() };
|
||||
let LinkSource::Text { at, angle, text, height, font, .. } = &sk.link(&link).unwrap().source else { panic!() };
|
||||
assert!(at.dist(Vec2::new(13.0, 2.0)) < 1e-9);
|
||||
assert!((*angle - 45_f64.to_radians()).abs() < 1e-12);
|
||||
assert_eq!((text.as_str(), *height, font), ("II", 10.0, &None));
|
||||
@@ -150,3 +150,67 @@ fn text_move_rejects_malformed_values_without_changing_history() {
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
/// The box around the solved outlines of text `link` in sketch 1: (min, max).
|
||||
fn text_box(s: &Session, link: &str) -> (Vec2, Vec2) {
|
||||
let state = s.world_state();
|
||||
let sk = &state.sketch(1).unwrap().sketch;
|
||||
let pts: Vec<Vec2> = sk.link_curves(link).into_iter().flat_map(|ci| sk.polyline(ci)).collect();
|
||||
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
|
||||
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_path_follows_its_curve_when_the_curve_moves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
// (Away from the origin, which would hold a line's end.)
|
||||
let line = run(&mut s, "sketch.line", json!({"points":[[0,-30],[80,-30]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
let t = run(&mut s, "sketch.text", json!({"text":"HH","at":[5,5],"height":10,"angle":30,"path":line,"align":"center"}));
|
||||
let link = t["link"].as_str().unwrap().to_string();
|
||||
// Centred on the line's middle, standing on it; `at` and the angle do not place it.
|
||||
let (lo, hi) = text_box(&s, &link);
|
||||
assert!(((lo.x + hi.x) / 2.0 - 40.0).abs() < 0.05 && (lo.y + 30.0).abs() < 1e-6 && (hi.y + 20.0).abs() < 0.05, "{lo:?} {hi:?}");
|
||||
// The line moves up 20 mm: the text with it.
|
||||
run(&mut s, "sketch.move", json!({"entities":[line],"translate":[0,20]}));
|
||||
let (lo, hi) = text_box(&s, &link);
|
||||
assert!((lo.y + 10.0).abs() < 1e-6 && ((lo.x + hi.x) / 2.0 - 40.0).abs() < 0.05, "{lo:?} {hi:?}");
|
||||
// It is moved with its curve, not on its own.
|
||||
let outline = s.doc.sketch(1).unwrap().curves.iter().find(|c| c.link.as_deref() == Some(link.as_str())).unwrap().id.clone();
|
||||
let e = s.execute("sketch.move", &json!({"entities":[outline],"translate":[5,0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("follows its curve"), "{e}");
|
||||
// Off the path again: at `at`, turned by its angle.
|
||||
run(&mut s, "sketch.edit_text", json!({"link":link,"path":"","align":"left"}));
|
||||
let (lo, _) = text_box(&s, &link);
|
||||
assert!(lo.x > 0.0 && lo.y > 4.0 && lo.y < 6.0, "{lo:?}");
|
||||
// A design with styled text reads back the same.
|
||||
let saved = s.doc.to_json();
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&saved).unwrap().to_json(), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_parameters_are_checked() {
|
||||
let (mut s, link, _) = setup();
|
||||
for (params, error) in [
|
||||
(json!({"link":link,"bold":true}), "no bold face"),
|
||||
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
|
||||
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
|
||||
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
|
||||
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
|
||||
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
|
||||
] {
|
||||
let before = s.doc.to_json();
|
||||
let e = s.execute("sketch.edit_text", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(error), "{params}: {e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
|
||||
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
|
||||
assert_eq!(text, "II");
|
||||
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
|
||||
// Left out, a style stays.
|
||||
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
|
||||
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
|
||||
assert!(style.italic && style.letter_spacing == 1.5);
|
||||
}
|
||||
@@ -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., font: None },
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None, style: Default::default() },
|
||||
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -223,7 +223,7 @@ 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 source = LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()), style: Default::default() };
|
||||
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.
|
||||
@@ -296,7 +296,14 @@ fn a_text_that_the_font_makes_too_large_is_refused() {
|
||||
|
||||
#[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) };
|
||||
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),
|
||||
style: Default::default(),
|
||||
};
|
||||
// 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();
|
||||
|
||||
+91
-15
@@ -69,8 +69,47 @@ pub fn font_data(name: Option<&str>) -> Result<FontData> {
|
||||
.ok_or_else(|| SketchError::Invalid(format!("unknown font `{name}`: not an installed font family, and not a .ttf/.otf file path")))
|
||||
}
|
||||
|
||||
/// The face of a font for a style: the bold and/or italic face of an installed family (or the
|
||||
/// file or built-in font itself). True with it: the font has no italic face, so the text is to
|
||||
/// be slanted instead. A bold style the font does not have is an error (bold is not made up).
|
||||
pub fn font_data_styled(name: Option<&str>, bold: bool, italic: bool) -> Result<(FontData, bool)> {
|
||||
let trimmed = name.map(str::trim).unwrap_or("");
|
||||
let installed = !(trimmed.is_empty() || normalize(trimmed) == normalize(DEFAULT_FONT_FAMILY) || is_path(trimmed));
|
||||
if !installed || !(bold || italic) {
|
||||
let data = font_data(name)?;
|
||||
let (face_bold, face_italic) = ttf_parser::Face::parse(data.bytes(), data.index)
|
||||
.map(|face| (face.is_bold(), face.is_italic() || face.is_oblique()))
|
||||
.map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
|
||||
if bold && !face_bold {
|
||||
let which = if trimmed.is_empty() || normalize(trimmed) == normalize(DEFAULT_FONT_FAMILY) {
|
||||
"the built-in font".to_string()
|
||||
} else {
|
||||
format!("the font file `{trimmed}`")
|
||||
};
|
||||
return Err(SketchError::Invalid(format!("{which} has no bold face: choose an installed font, or its bold file")));
|
||||
}
|
||||
return Ok((data, italic && !face_italic));
|
||||
}
|
||||
if trimmed.len() > MAX_FONT_NAME {
|
||||
return Err(SketchError::Invalid("the font name is too long".into()));
|
||||
}
|
||||
let Some((data, face_bold, face_italic)) = find_style_in(&font_dirs(), trimmed, bold, italic)? else {
|
||||
return Err(SketchError::Invalid(format!("unknown font `{trimmed}`: not an installed font family, and not a .ttf/.otf file path")));
|
||||
};
|
||||
if bold && !face_bold {
|
||||
return Err(SketchError::Invalid(format!("the font `{trimmed}` has no bold face")));
|
||||
}
|
||||
Ok((data, italic && !face_italic))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
Ok(find_style_in(dirs, name, false, false)?.map(|(data, _, _)| data))
|
||||
}
|
||||
|
||||
/// The installed face of `name` (a family, full or PostScript name) nearest to the style, in
|
||||
/// `dirs`, with whether it is bold and italic.
|
||||
pub fn find_style_in(dirs: &[PathBuf], name: &str, bold: bool, italic: bool) -> Result<Option<(FontData, bool, bool)>> {
|
||||
let want = normalize(name);
|
||||
if want.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -83,21 +122,21 @@ pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result<Option<FontData>>
|
||||
stem.len() >= 3 && (stem.contains(&want) || want.contains(&stem))
|
||||
});
|
||||
for group in [likely, rest] {
|
||||
let mut best: Option<(u32, PathBuf, u32)> = None;
|
||||
let mut best: Option<(u32, PathBuf, u32, bool, bool)> = 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));
|
||||
let score = face.score(&want, bold, italic);
|
||||
if score > 0 && best.as_ref().is_none_or(|b| score > b.0) {
|
||||
best = Some((score, path.clone(), face.index, face.bold, face.italic));
|
||||
}
|
||||
}
|
||||
if best.as_ref().is_some_and(|(s, _, _)| *s >= 4) {
|
||||
if best.as_ref().is_some_and(|b| b.0 >= FULL_NAME_SCORE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((_, path, index)) = best {
|
||||
if let Some((_, path, index, face_bold, face_italic)) = 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 }));
|
||||
return Ok(Some((FontData { bytes: FontBytes::File(bytes), index }, face_bold, face_italic)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
@@ -189,22 +228,30 @@ fn family_name(table: &ttf_parser::name::Table) -> Option<String> {
|
||||
/// The names of one face of a font file (normalised), for matching.
|
||||
struct FaceNames {
|
||||
index: u32,
|
||||
regular: bool,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
names: Vec<(u16, String)>,
|
||||
}
|
||||
|
||||
/// A face named by its full or PostScript name: that face, whatever the style asked for.
|
||||
const FULL_NAME_SCORE: u32 = 40;
|
||||
|
||||
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 {
|
||||
/// How well the face answers to a (normalised) name in a style: its full or PostScript
|
||||
/// name in that style, else its family's face of that style, else (lower) another face of
|
||||
/// the family, bold matching first, else its full name in another style (a regular face's
|
||||
/// full name is often the family's: "Arial" is not the bold Arial); 0 no match.
|
||||
fn score(&self, want: &str, bold: bool, italic: bool) -> u32 {
|
||||
use ttf_parser::name_id;
|
||||
let style =
|
||||
if self.bold == bold && self.italic == italic { 30 } else { 20 + 2 * u32::from(self.bold == bold) + u32::from(self.italic == italic) };
|
||||
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,
|
||||
name_id::FULL_NAME | name_id::POST_SCRIPT_NAME if self.bold == bold && self.italic == italic => FULL_NAME_SCORE,
|
||||
name_id::FULL_NAME | name_id::POST_SCRIPT_NAME => 10,
|
||||
name_id::FAMILY | name_id::TYPOGRAPHIC_FAMILY => style,
|
||||
_ => 0,
|
||||
})
|
||||
.max()
|
||||
@@ -242,7 +289,7 @@ fn face_names(path: &Path) -> Arc<Vec<FaceNames>> {
|
||||
Some((n.name_id, normalize(&text)))
|
||||
})
|
||||
.collect();
|
||||
faces.push(FaceNames { index, regular: face.is_regular() || (!face.is_bold() && !face.is_italic()), names });
|
||||
faces.push(FaceNames { index, bold: face.is_bold(), italic: face.is_italic() || face.is_oblique(), names });
|
||||
}
|
||||
}
|
||||
let faces = Arc::new(faces);
|
||||
@@ -401,3 +448,32 @@ fn read_font(path: &Path) -> std::result::Result<Arc<Vec<u8>>, String> {
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ttf_parser::name_id;
|
||||
|
||||
/// A face of family "Arial" whose full name is `full`.
|
||||
fn face(full: &str, bold: bool, italic: bool) -> FaceNames {
|
||||
FaceNames { index: 0, bold, italic, names: vec![(name_id::FAMILY, normalize("Arial")), (name_id::FULL_NAME, normalize(full))] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_picks_its_face_even_when_the_regular_face_is_named_like_the_family() {
|
||||
// As installed: the regular face's full name is the family's own.
|
||||
let faces =
|
||||
[face("Arial", false, false), face("Arial Bold", true, false), face("Arial Italic", false, true), face("Arial Bold Italic", true, true)];
|
||||
let best = |bold: bool, italic: bool| {
|
||||
let scores: Vec<u32> = faces.iter().map(|f| f.score("arial", bold, italic)).collect();
|
||||
let top = scores.iter().copied().max().unwrap_or(0);
|
||||
faces.iter().zip(&scores).find(|(_, s)| **s == top).map(|(f, _)| (f.bold, f.italic))
|
||||
};
|
||||
for style in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
assert_eq!(best(style.0, style.1), Some(style), "{style:?}");
|
||||
}
|
||||
// A full name still finds its face, whatever style is asked (it is the only one by that name).
|
||||
assert!(face("Arial Bold", true, false).score("arialbold", false, false) > 0);
|
||||
assert_eq!(face("Arial", false, false).score("times", false, false), 0);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +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, families_in, font_data, font_file_path, installed_families};
|
||||
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, font_file_path, installed_families};
|
||||
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};
|
||||
@@ -58,7 +58,7 @@ pub use solver::{
|
||||
Analysis, CONSISTENT, DrivingValues, Hold, SolveReport, SolveStatus, constraint_residual, determined_by, measure_dimension, solve,
|
||||
solve_after_edit, solve_after_value_edit, solve_holding, solve_with,
|
||||
};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry, text_geometry_in};
|
||||
pub use text::{MAX_TEXT_CHARS, TextAlign, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -81,6 +81,9 @@ pub enum LinkSource {
|
||||
angle: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
font: Option<String>,
|
||||
/// Bold or italic, alignment, spacing and the curve the text follows.
|
||||
#[serde(default, skip_serializing_if = "crate::text::TextStyle::is_plain")]
|
||||
style: crate::text::TextStyle,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -650,7 +650,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, font: None };
|
||||
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default() };
|
||||
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
|
||||
sk
|
||||
}
|
||||
|
||||
+290
-18
@@ -6,11 +6,12 @@
|
||||
//! 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 serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::fonts::font_data;
|
||||
use crate::link::{LinkGeom, MAX_LINK_ENTITIES};
|
||||
use crate::model::{Result, SketchError};
|
||||
use crate::fonts::font_data_styled;
|
||||
use crate::link::{LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
use crate::model::{Result, Sketch, SketchError};
|
||||
|
||||
/// Most characters in one text.
|
||||
pub const MAX_TEXT_CHARS: usize = 1000;
|
||||
@@ -80,6 +81,85 @@ impl ttf_parser::OutlineBuilder for Outline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each line of a text sits against its anchor (`at`, or a point of its path).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TextAlign {
|
||||
/// The line starts at the anchor.
|
||||
#[default]
|
||||
Left,
|
||||
/// The line is centred on it.
|
||||
Center,
|
||||
/// The line ends at it.
|
||||
Right,
|
||||
}
|
||||
|
||||
impl TextAlign {
|
||||
fn is_left(&self) -> bool {
|
||||
*self == TextAlign::Left
|
||||
}
|
||||
}
|
||||
|
||||
/// How a text is set: the style of its font, where its lines sit, the space between its letters
|
||||
/// and lines, and the sketch curve its baseline follows (instead of the straight line from `at`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextStyle {
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub bold: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub italic: bool,
|
||||
#[serde(default, skip_serializing_if = "TextAlign::is_left")]
|
||||
pub align: TextAlign,
|
||||
/// Extra space between letters (mm; negative brings them closer).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub letter_spacing: f64,
|
||||
/// The distance between lines as a multiple of the font's own.
|
||||
#[serde(default = "one", skip_serializing_if = "is_one")]
|
||||
pub line_spacing: f64,
|
||||
/// The id of a curve of the same sketch the baseline follows (`at` and the angle then do not
|
||||
/// place the text).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Along the path the other way round, so the text sits on its other side.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
impl Default for TextStyle {
|
||||
fn default() -> Self {
|
||||
TextStyle { bold: false, italic: false, align: TextAlign::Left, letter_spacing: 0.0, line_spacing: 1.0, path: None, reverse: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// Nothing but the defaults (what a text saved before styles had).
|
||||
pub fn is_plain(&self) -> bool {
|
||||
*self == TextStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn is_zero(x: &f64) -> bool {
|
||||
*x == 0.0
|
||||
}
|
||||
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn is_one(x: &f64) -> bool {
|
||||
*x == 1.0
|
||||
}
|
||||
|
||||
/// How far a slanted (made-up italic) glyph leans: 12 degrees.
|
||||
const SLANT_DEG: f64 = 12.0;
|
||||
/// Largest letter spacing (mm either way) and line spacing factor accepted.
|
||||
const MAX_LETTER_SPACING: f64 = 1e5;
|
||||
const LINE_SPACING_RANGE: (f64, f64) = (0.05, 100.0);
|
||||
|
||||
/// 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)
|
||||
@@ -89,43 +169,231 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
|
||||
/// 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>> {
|
||||
text_geometry_styled(font, text, at, height, angle, &TextStyle::default(), None)
|
||||
}
|
||||
|
||||
/// Outline geometry of `text` set in `style` (see [`text_geometry_in`]). With `path` (the
|
||||
/// points of the curve [`TextStyle::path`] names) the baseline follows it: each letter stands on
|
||||
/// the curve, turned with it, and the lines after the first run below it.
|
||||
pub fn text_geometry_styled(
|
||||
font: Option<&str>,
|
||||
text: &str,
|
||||
at: Vec2,
|
||||
height: f64,
|
||||
angle: f64,
|
||||
style: &TextStyle,
|
||||
path: Option<&[Vec2]>,
|
||||
) -> 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 data = font_data(font)?;
|
||||
if !(style.letter_spacing.is_finite() && style.letter_spacing.abs() <= MAX_LETTER_SPACING) {
|
||||
return Err(SketchError::Invalid("the letter spacing must be a finite length".into()));
|
||||
}
|
||||
if !(style.line_spacing.is_finite() && (LINE_SPACING_RANGE.0..=LINE_SPACING_RANGE.1).contains(&style.line_spacing)) {
|
||||
return Err(SketchError::Invalid(format!(
|
||||
"the line spacing must be between {} and {} times the font's",
|
||||
LINE_SPACING_RANGE.0, LINE_SPACING_RANGE.1
|
||||
)));
|
||||
}
|
||||
let (data, slant) = font_data_styled(font, style.bold, style.italic)?;
|
||||
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 k = height / cap_height(&face);
|
||||
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 line_gap = if line_gap > 0.0 { line_gap * k } else { height * 1.5 } * style.line_spacing;
|
||||
let shear = if slant { SLANT_DEG.to_radians().tan() } else { 0.0 };
|
||||
// Each line's glyphs (glyph, where its origin is along the line, its advance) and width.
|
||||
let lines: Vec<(Vec<(ttf_parser::GlyphId, f64, f64)>, f64)> = text
|
||||
.split('\n')
|
||||
.map(|line| {
|
||||
let mut pen = 0.0f64;
|
||||
let mut glyphs = Vec::new();
|
||||
for ch in line.chars() {
|
||||
let Some(gid) = face.glyph_index(ch).or_else(|| face.glyph_index('?')) else { continue };
|
||||
let advance = f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k;
|
||||
glyphs.push((gid, pen, advance));
|
||||
pen += advance + style.letter_spacing;
|
||||
}
|
||||
let width = glyphs.last().map(|(_, x, advance)| x + advance).unwrap_or(0.0);
|
||||
(glyphs, width)
|
||||
})
|
||||
.collect();
|
||||
let along = path.map(|pts| Path::new(pts, style.reverse)).transpose()?;
|
||||
let anchor = along.as_ref().map(|p| p.anchor(style.align)).unwrap_or(0.0);
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let mut out = Vec::new();
|
||||
for (row, line) in text.split('\n').enumerate() {
|
||||
let mut pen = 0.0f64;
|
||||
for (row, (glyphs, width)) in lines.iter().enumerate() {
|
||||
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 start = match style.align {
|
||||
TextAlign::Left => 0.0,
|
||||
TextAlign::Center => -width / 2.0,
|
||||
TextAlign::Right => -width,
|
||||
};
|
||||
for &(gid, x, advance) in glyphs {
|
||||
// The glyph's frame: its origin on the baseline and the way the baseline runs.
|
||||
let (origin, dir) = match &along {
|
||||
None => {
|
||||
let dir = Vec2::new(cos, sin);
|
||||
(at + dir * (start + x) + Vec2::new(-sin, cos) * base_y, dir)
|
||||
}
|
||||
Some(p) => {
|
||||
let (mid, dir) = p.frame(anchor + start + x + advance / 2.0, advance);
|
||||
(mid - dir * (advance / 2.0) + Vec2::new(-dir.y, dir.x) * base_y, dir)
|
||||
}
|
||||
};
|
||||
let up = Vec2::new(-dir.y, dir.x);
|
||||
let map = Box::new(move |gx: f32, gy: f32| {
|
||||
let (gx, gy) = (f64::from(gx) * k, f64::from(gy) * k);
|
||||
origin + dir * (gx + gy * shear) + up * gy
|
||||
});
|
||||
let mut ob = Outline { out: Vec::new(), start: at, cur: at, map };
|
||||
let mut ob = Outline { out: Vec::new(), start: origin, cur: origin, 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;
|
||||
}
|
||||
}
|
||||
if out.iter().any(|g| !geom_finite(g)) {
|
||||
return Err(SketchError::Invalid("the text does not fit its path".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn geom_finite(g: &LinkGeom) -> bool {
|
||||
match g {
|
||||
LinkGeom::Line(a, b) => a.is_finite() && b.is_finite(),
|
||||
LinkGeom::Conic { a, apex, b, rho } => a.is_finite() && apex.is_finite() && b.is_finite() && rho.is_finite(),
|
||||
LinkGeom::Spline { pts, .. } => pts.iter().all(|p| p.is_finite()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve a text follows, as points with the distance along it to each: oriented so the text
|
||||
/// reads left to right (an open curve runs rightwards, a closed one clockwise, the text on its
|
||||
/// outside) unless reversed.
|
||||
struct Path {
|
||||
pts: Vec<Vec2>,
|
||||
along: Vec<f64>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Path {
|
||||
fn new(points: &[Vec2], reverse: bool) -> Result<Path> {
|
||||
let mut pts: Vec<Vec2> = Vec::with_capacity(points.len());
|
||||
for p in points {
|
||||
if !p.is_finite() {
|
||||
return Err(SketchError::Invalid("the text's path is not a finite curve".into()));
|
||||
}
|
||||
if pts.last().is_none_or(|q: &Vec2| q.dist(*p) > 1e-9) {
|
||||
pts.push(*p);
|
||||
}
|
||||
}
|
||||
let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else {
|
||||
return Err(SketchError::Invalid("the text's path has no length".into()));
|
||||
};
|
||||
let length: f64 = pts.iter().zip(pts.iter().skip(1)).map(|(a, b)| a.dist(*b)).sum();
|
||||
if pts.len() < 2 || length < 1e-9 {
|
||||
return Err(SketchError::Invalid("the text's path has no length".into()));
|
||||
}
|
||||
let closed = first.dist(last) <= 1e-9 * length.max(1.0);
|
||||
let readable = if closed {
|
||||
// Clockwise: the text stands on the outside.
|
||||
let area: f64 = pts.iter().zip(pts.iter().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>() + last.cross(first);
|
||||
area <= 0.0
|
||||
} else {
|
||||
last.x >= first.x - 1e-9 * length
|
||||
};
|
||||
if readable == reverse {
|
||||
pts.reverse();
|
||||
}
|
||||
let mut along = Vec::with_capacity(pts.len());
|
||||
let mut total = 0.0;
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
|
||||
total += prev.dist(*p);
|
||||
}
|
||||
along.push(total);
|
||||
}
|
||||
Ok(Path { pts, along, closed })
|
||||
}
|
||||
|
||||
fn length(&self) -> f64 {
|
||||
self.along.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Where the lines are aligned to: an open curve's start, middle or end; on a closed curve
|
||||
/// its top, whatever the alignment.
|
||||
fn anchor(&self, align: TextAlign) -> f64 {
|
||||
if self.closed {
|
||||
return self.pts.iter().zip(&self.along).max_by(|(p, _), (q, _)| p.y.total_cmp(&q.y)).map(|(_, s)| *s).unwrap_or(0.0);
|
||||
}
|
||||
match align {
|
||||
TextAlign::Left => 0.0,
|
||||
TextAlign::Center => self.length() / 2.0,
|
||||
TextAlign::Right => self.length(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at distance `s` along the curve (around a closed one; past the ends of an open
|
||||
/// one, straight on).
|
||||
fn point(&self, s: f64) -> Vec2 {
|
||||
let length = self.length();
|
||||
let s = if self.closed { s.rem_euclid(length) } else { s };
|
||||
let (first, last) = (self.pts.first().copied().unwrap_or_default(), self.pts.last().copied().unwrap_or_default());
|
||||
if !self.closed && s <= 0.0 {
|
||||
let d = self.pts.get(1).and_then(|q| (*q - first).normalized()).unwrap_or(Vec2::X);
|
||||
return first + d * s;
|
||||
}
|
||||
if !self.closed && s >= length {
|
||||
let n = self.pts.len();
|
||||
let d = n.checked_sub(2).and_then(|i| self.pts.get(i)).and_then(|q| (last - *q).normalized()).unwrap_or(Vec2::X);
|
||||
return last + d * (s - length);
|
||||
}
|
||||
let i = self.along.partition_point(|a| *a < s).clamp(1, self.pts.len().saturating_sub(1).max(1));
|
||||
match (self.pts.get(i - 1), self.pts.get(i), self.along.get(i - 1), self.along.get(i)) {
|
||||
(Some(a), Some(b), Some(sa), Some(sb)) if sb > sa => *a + (*b - *a) * ((s - sa) / (sb - sa)),
|
||||
(Some(a), ..) => *a,
|
||||
_ => first,
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at `s` and the way the curve runs there, over a glyph `width` wide (the chord
|
||||
/// across it, so a glyph sits evenly on a bend).
|
||||
fn frame(&self, s: f64, width: f64) -> (Vec2, Vec2) {
|
||||
let h = (width / 2.0).max(self.length() * 1e-4).max(1e-9);
|
||||
let dir = (self.point(s + h) - self.point(s - h)).normalized().unwrap_or(Vec2::X);
|
||||
(self.point(s), dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
/// The outline geometry of a text source of this sketch: its path, when it has one, is a
|
||||
/// curve of this sketch as it is now.
|
||||
pub fn text_source_geometry(&self, src: &LinkSource) -> Result<Vec<LinkGeom>> {
|
||||
let LinkSource::Text { text, at, height, angle, font, style } = src else {
|
||||
return Err(SketchError::Invalid("not a text".into()));
|
||||
};
|
||||
let path = match &style.path {
|
||||
None => None,
|
||||
Some(id) => {
|
||||
let ci = self.curve_index(id).ok_or_else(|| SketchError::Invalid(format!("the text's path `{id}` is not a curve of this sketch")))?;
|
||||
let of_text =
|
||||
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);
|
||||
if of_text {
|
||||
return Err(SketchError::Invalid("a text cannot follow the outline of a text".into()));
|
||||
}
|
||||
Some(self.polyline(ci))
|
||||
}
|
||||
};
|
||||
text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, path.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -136,6 +404,10 @@ fn cap_height(face: &ttf_parser::Face) -> f64 {
|
||||
.unwrap_or(f64::from(face.units_per_em().max(1)) * 0.7)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_style_tests.rs"]
|
||||
mod style_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Text styles: alignment, letter and line spacing, a made-up italic, a bold the font lacks, and
|
||||
//! text that follows a curve of its sketch.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use super::*;
|
||||
use crate::{CurveKind, Sketch};
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
/// Every end and control point of the outlines.
|
||||
fn points(g: &[LinkGeom]) -> Vec<Vec2> {
|
||||
g.iter()
|
||||
.flat_map(|x| match x {
|
||||
LinkGeom::Line(a, b) => vec![*a, *b],
|
||||
LinkGeom::Conic { a, apex, b, .. } => vec![*a, *apex, *b],
|
||||
LinkGeom::Spline { pts, .. } => pts.clone(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The box around the outlines' end points: (min, max).
|
||||
fn extent(g: &[LinkGeom]) -> (Vec2, Vec2) {
|
||||
let pts: Vec<Vec2> = g
|
||||
.iter()
|
||||
.flat_map(|x| match x {
|
||||
LinkGeom::Line(a, b) | LinkGeom::Conic { a, b, .. } => vec![*a, *b],
|
||||
_ => vec![],
|
||||
})
|
||||
.collect();
|
||||
let lo = pts.iter().fold(v(f64::MAX, f64::MAX), |m, p| v(m.x.min(p.x), m.y.min(p.y)));
|
||||
let hi = pts.iter().fold(v(f64::MIN, f64::MIN), |m, p| v(m.x.max(p.x), m.y.max(p.y)));
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn styled(text: &str, style: &TextStyle, path: Option<&[Vec2]>) -> Vec<LinkGeom> {
|
||||
text_geometry_styled(None, text, Vec2::ZERO, 10.0, 0.0, style, path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plain_style_is_the_text_as_before() {
|
||||
assert!(TextStyle::default().is_plain());
|
||||
assert_eq!(styled("Ab\nC", &TextStyle::default(), None), text_geometry("Ab\nC", Vec2::ZERO, 10.0, 0.0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_align_left_center_or_right_on_the_anchor() {
|
||||
let left = extent(&styled("HH", &TextStyle::default(), None));
|
||||
let width = left.1.x - left.0.x;
|
||||
let center = extent(&styled("HH", &TextStyle { align: TextAlign::Center, ..TextStyle::default() }, None));
|
||||
let right = extent(&styled("HH", &TextStyle { align: TextAlign::Right, ..TextStyle::default() }, None));
|
||||
// H has the same side bearing both sides: centred, the ink is about the anchor; right, it ends left of it.
|
||||
assert!((center.0.x + center.1.x).abs() < 0.05, "{center:?}");
|
||||
assert!((right.1.x - right.0.x - width).abs() < 1e-9 && right.1.x < 0.0 && right.1.x > -2.0, "{right:?}");
|
||||
assert!((left.0.y - center.0.y).abs() < 1e-12, "alignment moves lines along, not up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_spacing_adds_between_letters_and_line_spacing_scales_the_lines() {
|
||||
let plain = extent(&styled("HHH", &TextStyle::default(), None));
|
||||
let spaced = extent(&styled("HHH", &TextStyle { letter_spacing: 2.5, ..TextStyle::default() }, None));
|
||||
assert!(((spaced.1.x - spaced.0.x) - (plain.1.x - plain.0.x) - 5.0).abs() < 1e-9, "two gaps of 2.5 mm");
|
||||
let one = extent(&styled("H\nH", &TextStyle::default(), None));
|
||||
let double = extent(&styled("H\nH", &TextStyle { line_spacing: 2.0, ..TextStyle::default() }, None));
|
||||
// The second line's baseline: one line distance below the first, then two.
|
||||
let gap = -one.0.y;
|
||||
assert!((-double.0.y - 2.0 * gap).abs() < 1e-9, "{gap} {double:?}");
|
||||
for bad in [TextStyle { line_spacing: 0.0, ..TextStyle::default() }, TextStyle { letter_spacing: f64::NAN, ..TextStyle::default() }] {
|
||||
assert!(text_geometry_styled(None, "H", Vec2::ZERO, 10.0, 0.0, &bad, None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italic_without_an_italic_face_slants_and_bold_without_a_bold_face_is_an_error() {
|
||||
// The built-in font has neither: its I leans 12 degrees, its top moved right by the height times tan 12°.
|
||||
let upright = extent(&styled("I", &TextStyle::default(), None));
|
||||
let slanted = extent(&styled("I", &TextStyle { italic: true, ..TextStyle::default() }, None));
|
||||
assert!((slanted.1.x - upright.1.x - 10.0 * 12_f64.to_radians().tan()).abs() < 1e-6, "{upright:?} {slanted:?}");
|
||||
assert!((slanted.0.x - upright.0.x).abs() < 1e-9, "the baseline stays");
|
||||
let e = text_geometry_styled(None, "I", Vec2::ZERO, 10.0, 0.0, &TextStyle { bold: true, ..TextStyle::default() }, None).unwrap_err();
|
||||
assert!(e.to_string().contains("no bold face"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_straight_path_stands_on_it_reading_left_to_right_whichever_way_it_was_drawn() {
|
||||
let plain = styled("Hi", &TextStyle::default(), None);
|
||||
let on = |path: &[Vec2], reverse: bool| styled("Hi", &TextStyle { path: Some("l".into()), reverse, ..TextStyle::default() }, Some(path));
|
||||
let same = |a: &[LinkGeom], b: &[LinkGeom]| points(a).iter().zip(points(b)).all(|(p, q)| p.dist(q) < 1e-9);
|
||||
// Along the x axis from the origin: the text as it is at the origin.
|
||||
assert!(same(&on(&[v(0.0, 0.0), v(100.0, 0.0)], false), &plain));
|
||||
// The same line drawn right to left still reads left to right, from its left end.
|
||||
assert!(same(&on(&[v(100.0, 0.0), v(0.0, 0.0)], false), &plain));
|
||||
// Reversed: right to left from the right end, upside down below the line.
|
||||
let (lo, hi) = extent(&on(&[v(0.0, 0.0), v(100.0, 0.0)], true));
|
||||
assert!(hi.y <= 1e-9 && lo.y < -5.0 && hi.x <= 100.0 + 1e-9 && lo.x > 80.0, "{lo:?} {hi:?}");
|
||||
// Centred on the path's middle, ended at its end.
|
||||
let c = extent(&styled(
|
||||
"HH",
|
||||
&TextStyle { path: Some("l".into()), align: TextAlign::Center, ..TextStyle::default() },
|
||||
Some(&[v(0.0, 0.0), v(100.0, 0.0)]),
|
||||
));
|
||||
assert!(((c.0.x + c.1.x) / 2.0 - 50.0).abs() < 0.05, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_circle_stands_outside_it_centred_on_top_and_inside_reversed() {
|
||||
let r = 30.0;
|
||||
let circle: Vec<Vec2> = (0..=360).map(|i| v(r * (i as f64).to_radians().cos(), r * (i as f64).to_radians().sin())).collect();
|
||||
let style = TextStyle { path: Some("c".into()), align: TextAlign::Center, ..TextStyle::default() };
|
||||
let outside = styled("TOP", &style, Some(&circle));
|
||||
// Every outline point is outside the circle (but for the O dipping below its baseline), and the text is about the top.
|
||||
assert!(points(&outside).iter().all(|p| p.len() >= r - 0.3), "outside");
|
||||
let (lo, hi) = extent(&outside);
|
||||
// (The ink is not quite symmetric: the P has a wide right side bearing.)
|
||||
assert!((lo.x + hi.x).abs() < 2.0 && lo.y > 0.0, "{lo:?} {hi:?}");
|
||||
let inside = styled("TOP", &TextStyle { reverse: true, ..style }, Some(&circle));
|
||||
assert!(points(&inside).iter().all(|p| p.len() <= r + 0.3), "inside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_sets_its_text_on_one_of_its_curves() {
|
||||
let mut sk = Sketch::new();
|
||||
let a = sk.add_point(v(0.0, 0.0), None).unwrap();
|
||||
let b = sk.add_point(v(80.0, 0.0), None).unwrap();
|
||||
let line = sk.add_curve(CurveKind::Line { a, b }, None).unwrap();
|
||||
let id = sk.curves[line].id.clone();
|
||||
let source = |path: &str| LinkSource::Text {
|
||||
text: "Hi".into(),
|
||||
at: v(5.0, 5.0),
|
||||
height: 10.0,
|
||||
angle: 0.7,
|
||||
font: None,
|
||||
style: TextStyle { path: Some(path.into()), ..TextStyle::default() },
|
||||
};
|
||||
// On the line: `at` and the angle do not place it.
|
||||
let on = sk.text_source_geometry(&source(&id)).unwrap();
|
||||
assert_eq!(on, styled("Hi", &TextStyle { path: Some("x".into()), ..TextStyle::default() }, Some(&[v(0.0, 0.0), v(80.0, 0.0)])));
|
||||
let e = sk.text_source_geometry(&source("nope")).unwrap_err().to_string();
|
||||
assert!(e.contains("not a curve of this sketch"), "{e}");
|
||||
// A text cannot follow its own (or another text's) outline.
|
||||
let text = sk.add_link(LinkKind::Text, source(&id), &on).unwrap();
|
||||
let outline = sk.curves.iter().find(|c| c.link.as_deref() == Some(text.as_str())).unwrap().id.clone();
|
||||
let e = sk.text_source_geometry(&source(&outline)).unwrap_err().to_string();
|
||||
assert!(e.contains("outline of a text"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
|
||||
let plain = LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 5.0, angle: 0.0, font: None, style: TextStyle::default() };
|
||||
let json = serde_json::to_value(&plain).unwrap();
|
||||
assert!(json.get("style").is_none(), "{json}");
|
||||
let styled = LinkSource::Text {
|
||||
text: "A".into(),
|
||||
at: Vec2::ZERO,
|
||||
height: 5.0,
|
||||
angle: 0.0,
|
||||
font: None,
|
||||
style: TextStyle {
|
||||
bold: true,
|
||||
italic: true,
|
||||
align: TextAlign::Right,
|
||||
letter_spacing: -0.5,
|
||||
line_spacing: 1.5,
|
||||
path: Some("l3".into()),
|
||||
reverse: true,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_value(&styled).unwrap();
|
||||
assert_eq!(json["style"]["align"], "right");
|
||||
assert_eq!(serde_json::from_value::<LinkSource>(json).unwrap(), styled);
|
||||
// Saved without a style (before styles): plain.
|
||||
let mut old = serde_json::to_value(&plain).unwrap();
|
||||
old.as_object_mut().unwrap().remove("style");
|
||||
assert_eq!(serde_json::from_value::<LinkSource>(old).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_a_closed_curve_left_alignment_starts_at_the_top() {
|
||||
let r = 30.0;
|
||||
let circle: Vec<Vec2> = (0..=360).map(|i| v(r * (i as f64).to_radians().cos(), r * (i as f64).to_radians().sin())).collect();
|
||||
let (lo, hi) = extent(&styled("START", &TextStyle { path: Some("c".into()), ..TextStyle::default() }, Some(&circle)));
|
||||
// It runs clockwise from the top: to the right of it, coming down.
|
||||
assert!(lo.x > -1.0 && hi.x > 10.0 && hi.y > r, "{lo:?} {hi:?}");
|
||||
}
|
||||
@@ -246,7 +246,7 @@ pub enum Kind {
|
||||
/// Insert Part from the standard parts library (`dialogs_parts`).
|
||||
Part(crate::dialogs_parts::Pt),
|
||||
/// Edit Text: a sketch text's content and placement (`sketch_text`).
|
||||
Text(crate::sketch_text::Tx),
|
||||
Text(Box<crate::sketch_text::Tx>),
|
||||
TextMove(crate::sketch_text_move::TextMove),
|
||||
/// Sketch-space modify tools: scale and offset, and later fillet, chamfer, the patterns,
|
||||
/// mirror and move (`sketch_modify_ui`).
|
||||
@@ -544,8 +544,10 @@ impl Dialog {
|
||||
inp.label = objects_label(inp.accept);
|
||||
}
|
||||
// Pre-selection: what is selected now becomes the input (first input that takes it); a
|
||||
// face or edge stands for its body where bodies are wanted.
|
||||
for sel in &s.selection {
|
||||
// face or edge stands for its body where bodies are wanted. (Edit Text's selection is the
|
||||
// text it edits, not its path.)
|
||||
let preselection: &[Sel] = if matches!(d.kind, Kind::Text(_)) { &[] } else { &s.selection };
|
||||
for sel in preselection {
|
||||
let as_body = match sel {
|
||||
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }),
|
||||
_ => None,
|
||||
@@ -1497,6 +1499,10 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
|
||||
/// makes it the active input.
|
||||
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
// Edit Text shows its Path input among its own rows.
|
||||
if matches!(d.kind, Kind::Text(_)) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut clear: Option<usize> = None;
|
||||
let mut activate: Option<usize> = None;
|
||||
@@ -1719,6 +1725,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
crate::sketch_text::sync_path(&mut d);
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
if let Kind::TextMove(k) = &d.kind
|
||||
&& !crate::sketch_text_move::valid(app, k)
|
||||
@@ -2458,7 +2465,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
|
||||
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k),
|
||||
Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k, &mut d.inputs, &mut d.active),
|
||||
Kind::TextMove(k) => enter |= crate::sketch_text_move::rows(ui, k),
|
||||
Kind::ConfirmDelete { with, fail, .. } => {
|
||||
if !with.is_empty() {
|
||||
|
||||
@@ -329,5 +329,13 @@
|
||||
"sketch_text.builtin_font": "{family} (integriert)",
|
||||
"sketch_text.font_search": "Schriftarten suchen",
|
||||
"sketch_text.loading_fonts": "Installierte Schriftarten werden gesucht…",
|
||||
"sketch_text.use_font_file": "Schriftdatei {path} verwenden"
|
||||
"sketch_text.use_font_file": "Schriftdatei {path} verwenden",
|
||||
"sketch_text.bold": "Fett",
|
||||
"sketch_text.italic": "Kursiv",
|
||||
"sketch_text.align_left": "Links",
|
||||
"sketch_text.align_center": "Mitte",
|
||||
"sketch_text.align_right": "Rechts",
|
||||
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
|
||||
"sketch_text.path_clear": "Text von der Kurve lösen",
|
||||
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage"
|
||||
}
|
||||
@@ -863,5 +863,18 @@
|
||||
"sketch_text.builtin_font": "{family} (built-in)",
|
||||
"sketch_text.font_search": "Search fonts",
|
||||
"sketch_text.loading_fonts": "Looking for installed fonts…",
|
||||
"sketch_text.use_font_file": "Use the font file {path}"
|
||||
"sketch_text.use_font_file": "Use the font file {path}",
|
||||
"sketch_text.bold": "Bold",
|
||||
"sketch_text.italic": "Italic",
|
||||
"sketch_text.align_left": "Left",
|
||||
"sketch_text.align_center": "Center",
|
||||
"sketch_text.align_right": "Right",
|
||||
"sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve",
|
||||
"sketch_text.path_clear": "Take the text off its curve",
|
||||
"Style": "Style",
|
||||
"Alignment": "Alignment",
|
||||
"Letter Spacing": "Letter Spacing",
|
||||
"Line Spacing": "Line Spacing",
|
||||
"Path": "Path",
|
||||
"sketch_text.on_path": "The text follows its path: the curve places it"
|
||||
}
|
||||
@@ -863,5 +863,18 @@
|
||||
"sketch_text.builtin_font": "{family}(内蔵)",
|
||||
"sketch_text.font_search": "フォントを検索",
|
||||
"sketch_text.loading_fonts": "インストール済みフォントを検索中…",
|
||||
"sketch_text.use_font_file": "フォント ファイル {path} を使用"
|
||||
"sketch_text.use_font_file": "フォント ファイル {path} を使用",
|
||||
"sketch_text.bold": "太字",
|
||||
"sketch_text.italic": "斜体",
|
||||
"sketch_text.align_left": "左",
|
||||
"sketch_text.align_center": "中央",
|
||||
"sketch_text.align_right": "右",
|
||||
"sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック",
|
||||
"sketch_text.path_clear": "文字を曲線から外す",
|
||||
"Style": "スタイル",
|
||||
"Alignment": "配置",
|
||||
"Letter Spacing": "文字間隔",
|
||||
"Line Spacing": "行間",
|
||||
"Path": "パス",
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります"
|
||||
}
|
||||
@@ -329,5 +329,13 @@
|
||||
"sketch_text.builtin_font": "{family}(내장)",
|
||||
"sketch_text.font_search": "글꼴 검색",
|
||||
"sketch_text.loading_fonts": "설치된 글꼴을 찾는 중…",
|
||||
"sketch_text.use_font_file": "글꼴 파일 {path} 사용"
|
||||
"sketch_text.use_font_file": "글꼴 파일 {path} 사용",
|
||||
"sketch_text.bold": "굵게",
|
||||
"sketch_text.italic": "기울임꼴",
|
||||
"sketch_text.align_left": "왼쪽",
|
||||
"sketch_text.align_center": "가운데",
|
||||
"sketch_text.align_right": "오른쪽",
|
||||
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
|
||||
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
|
||||
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다"
|
||||
}
|
||||
@@ -8,18 +8,27 @@ use serde_json::{Value, json};
|
||||
use solidcraft_engine::{
|
||||
Sel,
|
||||
doc::expr::Kind as ValueKind,
|
||||
sketch::{LinkKind, LinkSource},
|
||||
sketch::{LinkKind, LinkSource, TextAlign, TextStyle},
|
||||
};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Dialog, Kind};
|
||||
use crate::selection::SelInput;
|
||||
|
||||
/// Field rows after the text: position, height (lengths) and angle.
|
||||
const LABELS: [&str; 4] = ["X", "Y", "Height", "Angle"];
|
||||
const KINDS: [ValueKind; 4] = [ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle];
|
||||
/// Field rows after the text: position, height (lengths), angle, letter spacing (a length) and
|
||||
/// line spacing (a factor).
|
||||
const LABELS: [&str; FIELDS] = ["X", "Y", "Height", "Angle", "Letter Spacing", "Line Spacing"];
|
||||
const KINDS: [ValueKind; FIELDS] =
|
||||
[ValueKind::Length, ValueKind::Length, ValueKind::Length, ValueKind::Angle, ValueKind::Length, ValueKind::Unitless];
|
||||
const FIELDS: usize = 6;
|
||||
/// The field shown in degrees, and the one without a unit.
|
||||
const ANGLE: usize = 3;
|
||||
const LINE_SPACING: usize = 5;
|
||||
|
||||
/// The Edit Text dialog's state: the text being edited and its four values as expressions.
|
||||
/// No input is active: a click on the canvas picks no path until the Path chip is clicked.
|
||||
const NO_INPUT: usize = usize::MAX;
|
||||
|
||||
/// The Edit Text dialog's state: the text being edited and its values as expressions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Tx {
|
||||
sketch: u64,
|
||||
@@ -29,11 +38,16 @@ pub struct Tx {
|
||||
/// 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],
|
||||
values: [String; FIELDS],
|
||||
/// The fields as first shown; an untouched field keeps its exact saved value.
|
||||
initial: [String; 4],
|
||||
/// The saved source: x, y, height (mm) and angle (rad).
|
||||
original: [f64; 4],
|
||||
initial: [String; FIELDS],
|
||||
/// The saved source: x, y, height (mm), angle (rad), letter spacing (mm) and line spacing.
|
||||
original: [f64; FIELDS],
|
||||
/// Bold, italic, alignment, the path (the Path input's curve) and its direction as chosen.
|
||||
pub(crate) style: TextStyle,
|
||||
initial_style: TextStyle,
|
||||
/// The Path chip was clicked: the next curve clicked on the canvas is the path.
|
||||
picking: bool,
|
||||
pub(crate) unit: &'static str,
|
||||
/// Focus the text with all of it selected (typing replaces it).
|
||||
focus: bool,
|
||||
@@ -50,8 +64,8 @@ pub struct Tx {
|
||||
shown: Option<Source>,
|
||||
}
|
||||
|
||||
/// A text source as the dialog sees it: text, font (empty: built-in), x, y, height and angle.
|
||||
type Source = (String, String, [f64; 4]);
|
||||
/// A text source as the dialog sees it: text, font (empty: built-in), its values and its style.
|
||||
type Source = (String, String, [f64; FIELDS], TextStyle);
|
||||
|
||||
/// What the Text tool puts down, ready to be typed over.
|
||||
const PLACEHOLDER: &str = "Text";
|
||||
@@ -175,7 +189,7 @@ fn shown(value: f64, unit: &str) -> String {
|
||||
let text = format!("{value:.6}");
|
||||
let text = text.trim_end_matches('0').trim_end_matches('.');
|
||||
let text = if text == "-0" { "0" } else { text };
|
||||
format!("{text} {unit}")
|
||||
if unit.is_empty() { text.to_string() } else { format!("{text} {unit}") }
|
||||
}
|
||||
|
||||
/// The saved text source behind `link` in the active sketch: its text, its font (empty: the built-in
|
||||
@@ -186,23 +200,59 @@ fn source(app: &SolveApp, sketch: u64, link: &str) -> Option<Source> {
|
||||
}
|
||||
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, font } = &source.source else { return None };
|
||||
Some((text.clone(), font.clone().unwrap_or_default(), [at.x, at.y, *height, *angle]))
|
||||
let LinkSource::Text { text, at, height, angle, font, style } = &source.source else { return None };
|
||||
let values = [at.x, at.y, *height, *angle, style.letter_spacing, style.line_spacing];
|
||||
Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone()))
|
||||
}
|
||||
|
||||
fn fields(original: &[f64; 4], unit: &'static str, scale: f64) -> [String; 4] {
|
||||
fn fields(original: &[f64; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] {
|
||||
std::array::from_fn(|i| {
|
||||
let value = original.get(i).copied().unwrap_or_default();
|
||||
if i == 3 { shown(value.to_degrees(), "deg") } else { shown(value * scale, unit) }
|
||||
match i {
|
||||
ANGLE => shown(value.to_degrees(), "deg"),
|
||||
LINE_SPACING => shown(value, ""),
|
||||
_ => shown(value * scale, unit),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The dialog for a text: its Path input holds the curve the text follows; no input is active
|
||||
/// until the Path chip is clicked.
|
||||
fn dialog(tx: Box<Tx>) -> Dialog {
|
||||
let inputs = inputs_for(&tx);
|
||||
let mut d = Dialog::new(Kind::Text(tx), inputs);
|
||||
d.active = NO_INPUT;
|
||||
d
|
||||
}
|
||||
|
||||
fn inputs_for(tx: &Tx) -> Vec<SelInput> {
|
||||
let mut path = SelInput::new("Path", crate::selection::CURVES, false);
|
||||
path.items.extend(tx.style.path.iter().map(|id| Sel::SketchCurve { id: id.clone() }));
|
||||
vec![path]
|
||||
}
|
||||
|
||||
/// Each frame, before the rows: the path is the Path input's curve; a new pick ends picking.
|
||||
pub(crate) fn sync_path(d: &mut Dialog) {
|
||||
let Kind::Text(k) = &mut d.kind else { return };
|
||||
let picked = d.inputs.first().and_then(|i| i.items.first()).and_then(|sel| match sel {
|
||||
Sel::SketchCurve { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if picked != k.style.path {
|
||||
k.style.path = picked;
|
||||
k.picking = false;
|
||||
}
|
||||
if !k.picking {
|
||||
d.active = NO_INPUT;
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog's kind for the text that `entity` (an outline curve) belongs to.
|
||||
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, font, original) = source(app, sketch, &link)?;
|
||||
let (text, font, original, style) = source(app, sketch, &link)?;
|
||||
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let values = fields(&original, unit, scale);
|
||||
let mut tx = Tx {
|
||||
@@ -216,6 +266,9 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
|
||||
values,
|
||||
original,
|
||||
unit,
|
||||
initial_style: style.clone(),
|
||||
style,
|
||||
picking: false,
|
||||
focus: true,
|
||||
font_filter: String::new(),
|
||||
start: None,
|
||||
@@ -225,7 +278,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
|
||||
};
|
||||
// The canvas already shows the fields as they open.
|
||||
tx.tried = edit_params(app, &tx).ok();
|
||||
Some(Kind::Text(tx))
|
||||
Some(Kind::Text(Box::new(tx)))
|
||||
}
|
||||
|
||||
/// `Dialog::for_command`: Edit Text with one text's outlines selected.
|
||||
@@ -233,17 +286,19 @@ pub(crate) fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
if id != "sketch.edit_text" {
|
||||
return None;
|
||||
}
|
||||
Some((kind_for(app, &selected_entity(app)?)?, Vec::new()))
|
||||
let Kind::Text(tx) = kind_for(app, &selected_entity(app)?)? else { return None };
|
||||
let inputs = inputs_for(&tx);
|
||||
Some((Kind::Text(tx), inputs))
|
||||
}
|
||||
|
||||
/// Open the dialog for the text an outline (picked with the Edit Text tool) belongs to.
|
||||
pub(crate) fn open(app: &mut SolveApp, entity: &str) {
|
||||
match kind_for(app, entity) {
|
||||
Some(kind) => {
|
||||
app.dialog = Some(Dialog::new(kind, Vec::new()));
|
||||
Some(Kind::Text(tx)) => {
|
||||
app.dialog = Some(dialog(tx));
|
||||
app.tool = None;
|
||||
}
|
||||
None => app.set_status("Choose an outline belonging to sketch text", true),
|
||||
_ => app.set_status("Choose an outline belonging to sketch text", true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +313,7 @@ pub(crate) fn place(app: &mut SolveApp, at: solidcraft_engine::geom::Vec2, heigh
|
||||
Some(Kind::Text(mut tx)) => {
|
||||
tx.start = Some(checkpoint);
|
||||
tx.folded = true;
|
||||
app.dialog = Some(Dialog::new(Kind::Text(tx), Vec::new()));
|
||||
app.dialog = Some(dialog(tx));
|
||||
app.tool = None;
|
||||
}
|
||||
_ => {
|
||||
@@ -333,7 +388,13 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
|
||||
if k.shown.as_ref() == Some(¤t) {
|
||||
return true;
|
||||
}
|
||||
let (text, font, original) = current;
|
||||
let (text, font, original, style) = current;
|
||||
if style != k.initial_style {
|
||||
if k.style == k.initial_style {
|
||||
k.style = style.clone();
|
||||
}
|
||||
k.initial_style = style;
|
||||
}
|
||||
if original != k.original {
|
||||
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let fresh = fields(&original, unit, scale);
|
||||
@@ -362,8 +423,8 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
|
||||
}
|
||||
|
||||
/// The dialog's rows; true when Enter was pressed in a value field or in the text (Shift+Enter
|
||||
/// starts a new line there).
|
||||
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
|
||||
/// starts a new line there). `inputs` and `active`: the dialog's Path input.
|
||||
pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) -> bool {
|
||||
let mut enter = false;
|
||||
crate::dialogs::row_label(ui, "Text");
|
||||
let width = ui.spacing().text_edit_width;
|
||||
@@ -385,14 +446,84 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx) -> bool {
|
||||
crate::dialogs::row_label(ui, "Font");
|
||||
font_row(ui, k, width);
|
||||
ui.end_row();
|
||||
for (label, value) in LABELS.iter().zip(k.values.iter_mut()) {
|
||||
crate::dialogs::row_label(ui, "Style");
|
||||
ui.horizontal(|ui| {
|
||||
let bold = ui.toggle_value(&mut k.style.bold, egui::RichText::new("B").strong()).on_hover_text(crate::i18n::tr("sketch_text.bold"));
|
||||
crate::scenario::publish_handle("dialog:Bold", bold.rect.center());
|
||||
let italic = ui.toggle_value(&mut k.style.italic, egui::RichText::new("I").italics()).on_hover_text(crate::i18n::tr("sketch_text.italic"));
|
||||
crate::scenario::publish_handle("dialog:Italic", italic.rect.center());
|
||||
});
|
||||
ui.end_row();
|
||||
crate::dialogs::row_label(ui, "Alignment");
|
||||
ui.horizontal(|ui| {
|
||||
for (align, id, handle) in [
|
||||
(TextAlign::Left, "sketch_text.align_left", "dialog:Align left"),
|
||||
(TextAlign::Center, "sketch_text.align_center", "dialog:Align center"),
|
||||
(TextAlign::Right, "sketch_text.align_right", "dialog:Align right"),
|
||||
] {
|
||||
let response = ui.selectable_value(&mut k.style.align, align, crate::i18n::tr(id));
|
||||
crate::scenario::publish_handle(handle, response.rect.center());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
// On a path the curve places the text: X, Y and the angle do not.
|
||||
let on_path = k.style.path.is_some();
|
||||
for (i, (label, value)) in LABELS.iter().zip(k.values.iter_mut()).enumerate() {
|
||||
crate::dialogs::row_label(ui, label);
|
||||
enter |= crate::dialogs::field(ui, value);
|
||||
let placed = on_path && matches!(i, 0 | 1 | ANGLE);
|
||||
if placed {
|
||||
let response = ui.add_enabled_ui(false, |ui| crate::dialogs::field(ui, value));
|
||||
response.response.on_disabled_hover_text(crate::i18n::tr("sketch_text.on_path"));
|
||||
} else {
|
||||
enter |= crate::dialogs::field(ui, value);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
path_row(ui, k, inputs, active);
|
||||
enter
|
||||
}
|
||||
|
||||
/// The curve the text follows: a chip that, clicked, takes the next curve clicked on the canvas;
|
||||
/// × takes the text off it; Reverse runs it the other way along.
|
||||
fn path_row(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
crate::dialogs::row_label(ui, "Path");
|
||||
ui.horizontal(|ui| {
|
||||
let on = k.style.path.is_some();
|
||||
let text = if on {
|
||||
egui::RichText::new(crate::i18n::format("dialog.selected_count", &[("count", "1")])).color(egui::Color32::WHITE)
|
||||
} else {
|
||||
egui::RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
|
||||
};
|
||||
let stroke = if k.picking { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
|
||||
let chip = egui::Button::new(text).fill(if on { t.chip } else { egui::Color32::TRANSPARENT }).stroke(stroke).min_size(egui::vec2(0.0, 22.0));
|
||||
let chip = ui.add(chip).on_hover_text(crate::i18n::tr("sketch_text.path_hint"));
|
||||
crate::scenario::publish_handle("dialog:Path", chip.rect.center());
|
||||
if chip.clicked() {
|
||||
k.picking = true;
|
||||
*active = 0;
|
||||
}
|
||||
if on {
|
||||
let clear = ui.small_button("\u{d7}").on_hover_text(crate::i18n::tr("sketch_text.path_clear"));
|
||||
crate::scenario::publish_handle("dialog:Path clear", clear.rect.center());
|
||||
if clear.clicked() {
|
||||
for input in inputs.iter_mut() {
|
||||
input.items.clear();
|
||||
}
|
||||
k.style.path = None;
|
||||
k.picking = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
if k.style.path.is_some() {
|
||||
crate::dialogs::row_label(ui, "Reverse");
|
||||
let reverse = ui.checkbox(&mut k.style.reverse, "");
|
||||
crate::scenario::publish_handle("dialog:Reverse", reverse.rect.center());
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed font families, listed once in the background (none on the web).
|
||||
fn families(ctx: &egui::Context) -> Option<Arc<Vec<String>>> {
|
||||
static FAMILIES: OnceLock<Arc<Vec<String>>> = OnceLock::new();
|
||||
@@ -503,7 +634,7 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
|
||||
/// `sketch.edit_text`'s parameters for the fields as they are.
|
||||
fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
|
||||
let doc = &app.session.doc;
|
||||
let mut out = [0.0; 4];
|
||||
let mut out = [0.0; FIELDS];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
let (Some(value), Some(initial), Some(original), Some(kind), Some(label)) =
|
||||
(k.values.get(i), k.initial.get(i), k.original.get(i), KINDS.get(i), LABELS.get(i))
|
||||
@@ -520,8 +651,18 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
|
||||
v
|
||||
};
|
||||
}
|
||||
let [x, y, height, angle] = out;
|
||||
let mut params = json!({"link": k.link, "text": k.text, "at": [x, y], "height": height});
|
||||
let [x, y, height, angle, letter_spacing, line_spacing] = out;
|
||||
let align = match k.style.align {
|
||||
TextAlign::Left => "left",
|
||||
TextAlign::Center => "center",
|
||||
TextAlign::Right => "right",
|
||||
};
|
||||
let mut params = json!({
|
||||
"link": k.link, "text": k.text, "at": [x, y], "height": height,
|
||||
"bold": k.style.bold, "italic": k.style.italic, "align": align,
|
||||
"letter_spacing": letter_spacing, "line_spacing": line_spacing,
|
||||
"path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse,
|
||||
});
|
||||
// Omitting an unchanged angle keeps its exact saved radians.
|
||||
if k.values.get(3) != k.initial.get(3) {
|
||||
params["angle"] = json!(angle.to_degrees());
|
||||
@@ -670,6 +811,7 @@ mod tests {
|
||||
height: 20.0,
|
||||
angle: std::f64::consts::FRAC_PI_2,
|
||||
font: None,
|
||||
style: TextStyle::default(),
|
||||
};
|
||||
assert_eq!(sk.link(&link).unwrap().source, expected);
|
||||
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
|
||||
@@ -697,7 +839,7 @@ mod tests {
|
||||
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, font: None }
|
||||
LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::default() }
|
||||
);
|
||||
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");
|
||||
@@ -1105,4 +1247,97 @@ mod tests {
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.session.selection.len(), 2);
|
||||
}
|
||||
|
||||
/// The style of text `link` in the active sketch.
|
||||
fn style_of(h: &Harness, link: &str) -> TextStyle {
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
match &h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source {
|
||||
LinkSource::Text { style, .. } => style.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_alignment_and_spacing_show_live_and_apply_in_one_step() {
|
||||
let (mut h, link, _) = setup();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.app.start("sketch.edit_text");
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:Italic"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Align center"}})).unwrap();
|
||||
replace(&mut h, "dialog:Letter Spacing", "1.5");
|
||||
replace(&mut h, "dialog:Line Spacing", "1.25");
|
||||
h.frames(2);
|
||||
// On the canvas before OK.
|
||||
let live = style_of(&h, &link);
|
||||
assert!(live.italic && !live.bold && live.align == TextAlign::Center, "{live:?}");
|
||||
assert_eq!((live.letter_spacing, live.line_spacing), (1.5, 1.25));
|
||||
ok(&mut h);
|
||||
assert!(!editing(&h.app));
|
||||
assert_eq!(style_of(&h, &link), live);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
// Reopened, the dialog shows them.
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
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);
|
||||
assert_eq!(tx(&h.app).style, live);
|
||||
assert_eq!(tx(&h.app).values[4], "1.5 mm");
|
||||
assert_eq!(tx(&h.app).values[5], "1.25");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_in_a_font_without_a_bold_face_is_refused_with_the_reason() {
|
||||
let (mut h, link, _) = setup();
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
h.app.start("sketch.edit_text");
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"dialog:Bold"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(!style_of(&h, &link).bold, "the canvas keeps the last text it could make");
|
||||
ok(&mut h);
|
||||
assert!(editing(&h.app));
|
||||
let error = h.app.dialog.as_ref().unwrap().error.clone().unwrap_or_default();
|
||||
assert!(error.contains("no bold face"), "{error}");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_chip_takes_the_next_curve_clicked_and_the_text_follows_it() {
|
||||
let (mut h, link, curve) = setup();
|
||||
let line = h.app.run("sketch.line", json!({"points":[[-20,-30],[40,-30]]})).unwrap()["curves"][0].as_str().unwrap().to_string();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 120.0;
|
||||
h.frames(3);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
h.app.session.selection = vec![Sel::SketchCurve { id: curve }];
|
||||
h.app.start("sketch.edit_text");
|
||||
h.frames(3);
|
||||
assert_eq!(style_of(&h, &link).path, None, "the selected text is not its own path");
|
||||
// A click on the canvas before the chip picks nothing.
|
||||
h.step(&json!({"click":{"sketch":[10,-30]}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(tx(&h.app).style.path, None);
|
||||
// The chip, then the line: the text stands on it at once.
|
||||
h.step(&json!({"click":{"handle":"dialog:Path"}})).unwrap();
|
||||
h.step(&json!({"click":{"sketch":[10,-30]}})).unwrap();
|
||||
h.frames(3);
|
||||
assert_eq!(tx(&h.app).style.path.as_deref(), Some(line.as_str()));
|
||||
assert_eq!(style_of(&h, &link).path.as_deref(), Some(line.as_str()));
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(sid).unwrap().sketch;
|
||||
let low = sk.link_curves(&link).into_iter().flat_map(|ci| sk.polyline(ci)).map(|p| p.y).fold(f64::MAX, f64::min);
|
||||
assert!((low + 30.0).abs() < 1e-6, "on the line: {low}");
|
||||
// Reverse, then OK: one undo step.
|
||||
h.step(&json!({"click":{"handle":"dialog:Reverse"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(style_of(&h, &link).reverse);
|
||||
// × takes it off again.
|
||||
h.step(&json!({"click":{"handle":"dialog:Path clear"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(style_of(&h, &link).path, None);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(style_of(&h, &link), TextStyle::default());
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,11 @@ fn frame(app: &SolveApp, link: &str) -> Option<Frame> {
|
||||
let state = app.session.world_state();
|
||||
let sk = &state.sketch(sid)?.sketch;
|
||||
let source = sk.link(link).filter(|l| l.kind == LinkKind::Text)?;
|
||||
let LinkSource::Text { at, height, angle, .. } = &source.source else { return None };
|
||||
let LinkSource::Text { at, height, angle, style, .. } = &source.source else { return None };
|
||||
// A text on a path follows its curve: it is not placed by hand.
|
||||
if style.path.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut f = Frame { link: link.to_owned(), at: *at, height: *height, angle: *angle, min: Vec2::ZERO, max: Vec2::ZERO };
|
||||
let mut first = true;
|
||||
for p in sk.link_curves(link).into_iter().flat_map(|ci| sk.polyline(ci)) {
|
||||
|
||||
@@ -67,7 +67,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
|
||||
The default `cache: false` omits the portable thumbnail too.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 17, including face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
Current designs use format 18, including the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, 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
|
||||
|
||||
Reference in new issue
Block a user