feat: a sketch text's height, place and angle can be expressions (#1453)

A sketch text's height, place (x and y) and angle can each be an expression over the design's parameters, and the text follows parameter changes, undo and reopening. Closes #1362.

Co-authored-by: NOWN-DEV <NOWN-DEV@users.noreply.github.com>
This commit is contained in:
NOWN-DEVandNOWN-DEV authored and GitHub committed 2026-10-15 14:57:36 -04:00
1 parent e20cf1858a
commit b6806b16cf
23 files changed
+751 -70

No files matched your search

+3 -1
View File
@@ -223,7 +223,8 @@ 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()), style: Default::default() };
let source =
LinkSource::Text { text: "O".into(), at, height, angle: 0.0, font: Some(font.clone()), style: Default::default(), exprs: 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.
@@ -303,6 +304,7 @@ fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() {
angle: 0.0,
font: font.map(str::to_string),
style: Default::default(),
exprs: 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.
+1 -1
View File
@@ -46,7 +46,7 @@ pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
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 link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom};
pub use model::{
Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_COORD, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView,
};
+62
View File
@@ -84,9 +84,47 @@ pub enum LinkSource {
/// 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,
/// The expressions behind the numbers above that were typed as expressions.
#[serde(default, skip_serializing_if = "TextExprs::is_empty")]
exprs: TextExprs,
},
}
/// The expressions behind a text's numbers that were typed as expressions: its place (`x`, `y`),
/// its height and its angle. A number without an expression is the number typed. The numbers
/// of `LinkSource::Text` are what the expressions gave last: the document evaluates them against
/// its parameters before the text's outlines are rebuilt (`Document::apply_text_values`), and
/// the sketch crate, which knows no parameters, only keeps them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TextExprs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub angle: Option<String>,
}
impl TextExprs {
pub fn is_empty(&self) -> bool {
*self == TextExprs::default()
}
/// The expressions set, each with what it sets.
pub fn list(&self) -> Vec<(&'static str, &String)> {
[("Text X", &self.x), ("Text Y", &self.y), ("Text height", &self.height), ("Text angle", &self.angle)]
.into_iter()
.filter_map(|(what, e)| e.as_ref().map(|e| (what, e)))
.collect()
}
pub fn list_mut(&mut self) -> Vec<&mut String> {
[&mut self.x, &mut self.y, &mut self.height, &mut self.angle].into_iter().filter_map(Option::as_mut).collect()
}
}
impl LinkSource {
/// Short description for messages and the browser.
pub fn describe(&self) -> String {
@@ -482,6 +520,30 @@ impl Sketch {
self.links.iter().find(|l| l.id == id)
}
/// The expressions of the sketch's texts, each with what it sets (`Text height`…).
pub fn text_exprs(&self) -> Vec<(&'static str, &String)> {
self.links
.iter()
.filter_map(|l| match &l.source {
LinkSource::Text { exprs, .. } => Some(exprs.list()),
_ => None,
})
.flatten()
.collect()
}
/// Mutable view of [`Sketch::text_exprs`], for renaming the parameters they refer to.
pub fn text_exprs_mut(&mut self) -> Vec<&mut String> {
self.links
.iter_mut()
.filter_map(|l| match &mut l.source {
LinkSource::Text { exprs, .. } => Some(exprs.list_mut()),
_ => None,
})
.flatten()
.collect()
}
/// Curves owned by a link, in creation order.
pub fn link_curves(&self, id: &str) -> Vec<usize> {
self.curves.iter().enumerate().filter(|(_, c)| c.link.as_deref() == Some(id)).map(|(i, _)| i).collect()
+1 -1
View File
@@ -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, style: Default::default() };
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default(), exprs: Default::default() };
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
sk
}
+1 -1
View File
@@ -375,7 +375,7 @@ 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 {
let LinkSource::Text { text, at, height, angle, font, style, .. } = src else {
return Err(SketchError::Invalid("not a text".into()));
};
let path = match &style.path {
+11 -1
View File
@@ -134,6 +134,7 @@ fn a_sketch_sets_its_text_on_one_of_its_curves() {
angle: 0.7,
font: None,
style: TextStyle { path: Some(path.into()), ..TextStyle::default() },
exprs: Default::default(),
};
// On the line: `at` and the angle do not place it.
let on = sk.text_source_geometry(&source(&id)).unwrap();
@@ -149,7 +150,15 @@ fn a_sketch_sets_its_text_on_one_of_its_curves() {
#[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 plain = LinkSource::Text {
text: "A".into(),
at: Vec2::ZERO,
height: 5.0,
angle: 0.0,
font: None,
style: TextStyle::default(),
exprs: Default::default(),
};
let json = serde_json::to_value(&plain).unwrap();
assert!(json.get("style").is_none(), "{json}");
let styled = LinkSource::Text {
@@ -167,6 +176,7 @@ fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
path: Some("l3".into()),
reverse: true,
},
exprs: Default::default(),
};
let json = serde_json::to_value(&styled).unwrap();
assert_eq!(json["style"]["align"], "right");