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)) {
|
||||
|
||||
Reference in new issue
Block a user