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

+127 -18
View File
@@ -23,6 +23,8 @@ const KINDS: [ValueKind; FIELDS] =
const FIELDS: usize = 6;
/// The field shown in degrees, and the one without a unit.
const ANGLE: usize = 3;
/// The fields before this one (X, Y, Height, Angle) can hold an expression of parameters.
const ANGLE_END: usize = 4;
const LINE_SPACING: usize = 5;
/// No input is active: a click on the canvas picks no path until the Path chip is clicked.
@@ -43,6 +45,9 @@ pub struct Tx {
initial: [String; FIELDS],
/// The saved source: x, y, height (mm), angle (rad), letter spacing (mm) and line spacing.
original: [f64; FIELDS],
/// The expressions saved behind x, y, height and angle (a field shows its expression, and
/// keeps it while untouched).
exprs: [Option<String>; FIELDS],
/// Bold, italic, alignment, the path (the Path input's curve) and its direction as chosen.
pub(crate) style: TextStyle,
initial_style: TextStyle,
@@ -64,8 +69,9 @@ pub struct Tx {
shown: Option<Source>,
}
/// 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);
/// A text source as the dialog sees it: text, font (empty: built-in), its values, its style and
/// the expressions behind its values.
type Source = (String, String, [f64; FIELDS], TextStyle, [Option<String>; FIELDS]);
/// What the Text tool puts down, ready to be typed over.
const PLACEHOLDER: &str = "Text";
@@ -200,13 +206,18 @@ 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, style } = &source.source else { return None };
let LinkSource::Text { text, at, height, angle, font, style, exprs } = &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()))
let exprs = [exprs.x.clone(), exprs.y.clone(), exprs.height.clone(), exprs.angle.clone(), None, None];
Some((text.clone(), font.clone().unwrap_or_default(), values, style.clone(), exprs))
}
fn fields(original: &[f64; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] {
/// The fields as shown: a value, or the expression behind it.
fn fields(original: &[f64; FIELDS], exprs: &[Option<String>; FIELDS], unit: &'static str, scale: f64) -> [String; FIELDS] {
std::array::from_fn(|i| {
if let Some(Some(e)) = exprs.get(i) {
return e.clone();
}
let value = original.get(i).copied().unwrap_or_default();
match i {
ANGLE => shown(value.to_degrees(), "deg"),
@@ -252,9 +263,9 @@ 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, style) = source(app, sketch, &link)?;
let (text, font, original, style, exprs) = source(app, sketch, &link)?;
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let values = fields(&original, unit, scale);
let values = fields(&original, &exprs, unit, scale);
let mut tx = Tx {
sketch,
link,
@@ -265,6 +276,7 @@ fn kind_for(app: &SolveApp, entity: &str) -> Option<Kind> {
initial: values.clone(),
values,
original,
exprs,
unit,
initial_style: style.clone(),
style,
@@ -388,16 +400,16 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
if k.shown.as_ref() == Some(&current) {
return true;
}
let (text, font, original, style) = current;
let (text, font, original, style, exprs) = current;
if style != k.initial_style {
if k.style == k.initial_style {
k.style = style.clone();
}
k.initial_style = style;
}
if original != k.original {
if original != k.original || exprs != k.exprs {
let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units);
let fresh = fields(&original, unit, scale);
let fresh = fields(&original, &exprs, unit, scale);
for ((value, initial), new) in k.values.iter_mut().zip(k.initial.iter_mut()).zip(fresh) {
if value == initial {
*value = new.clone();
@@ -405,6 +417,7 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool {
*initial = new;
}
k.original = original;
k.exprs = exprs;
k.unit = unit;
}
if text != k.initial_text {
@@ -631,41 +644,53 @@ pub(crate) fn commands(app: &SolveApp, k: &Tx) -> Result<Vec<(String, Value)>, S
Ok(vec![("sketch.edit_text".into(), edit_params(app, k)?)])
}
/// `sketch.edit_text`'s parameters for the fields as they are.
/// `sketch.edit_text`'s parameters for the fields as they are. X, Y, Height and Angle go as
/// numbers, or as their expressions when these name parameters (the text then follows them): an
/// untouched field that holds one keeps it.
fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
let doc = &app.session.doc;
let mut out = [0.0; FIELDS];
let mut sent: [Value; FIELDS] = std::array::from_fn(|_| Value::Null);
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))
else {
return Err("missing value".into());
};
let given = k.exprs.get(i).and_then(Option::as_ref).filter(|_| value == initial);
*slot = if value == initial {
*original
} else {
let v = doc.eval(&doc.with_design_unit(value.trim(), *kind), *kind).map_err(|e| format!("{label}: {e}"))?;
let typed = doc.with_design_unit(value.trim(), *kind);
let v = doc.eval(&typed, *kind).map_err(|e| format!("{label}: {e}"))?;
if !v.is_finite() {
return Err(format!("{label}: not a finite number"));
}
v
};
let names_parameters = i < ANGLE_END && !solidcraft_engine::doc::expr::references(value).is_empty();
sent[i] = match given {
Some(e) => json!(e),
None if value != initial && names_parameters => json!(value.trim()),
None => json!(if i == ANGLE { slot.to_degrees() } else { *slot }),
};
}
let [x, y, height, angle, letter_spacing, line_spacing] = out;
let [_, _, _, _, letter_spacing, line_spacing] = out;
let [sent_x, sent_y, sent_height, sent_angle, ..] = sent;
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,
"link": k.link, "text": k.text, "at": [sent_x, sent_y], "height": sent_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());
// Omitting an unchanged angle keeps its exact saved radians (or its expression).
if k.values.get(ANGLE) != k.initial.get(ANGLE) {
params["angle"] = sent_angle;
}
// An untouched font stays as saved; an emptied one is the built-in font.
if k.font.trim() != k.initial_font.trim() {
@@ -812,6 +837,7 @@ mod tests {
angle: std::f64::consts::FRAC_PI_2,
font: None,
style: TextStyle::default(),
exprs: Default::default(),
};
assert_eq!(sk.link(&link).unwrap().source, expected);
assert!(!h.app.session.world_state().sketch(sid).unwrap().profiles.is_empty());
@@ -839,7 +865,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, style: TextStyle::default() }
LinkSource::Text { text: "O".into(), at, height, angle, font: None, style: TextStyle::default(), exprs: Default::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");
@@ -1020,6 +1046,89 @@ mod tests {
assert!(sk.links.iter().any(|l| matches!(&l.source, LinkSource::Text { text, .. } if text == "Pending text")));
}
/// Selects the text `link` again (an edit makes its outlines anew).
fn select_text(h: &mut Harness, link: &str) {
let sid = h.app.session.active_sketch.unwrap();
let sk = h.app.session.doc.sketch(sid).unwrap();
let id = sk.curves[sk.link_curves(link)[0]].id.clone();
h.app.session.selection = vec![Sel::SketchCurve { id }];
h.frames(3);
}
fn exprs_of(h: &Harness, link: &str) -> solidcraft_engine::sketch::TextExprs {
let sid = h.app.session.active_sketch.unwrap();
let LinkSource::Text { exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(link).unwrap().source.clone() else { panic!("text source") };
exprs
}
#[test]
fn a_field_that_names_parameters_stays_an_expression_and_is_shown_as_typed() {
let (mut h, link, _) = setup();
h.app.run("parameters.add", json!({"name":"tall","expression":"20 mm"})).unwrap();
h.app.run("parameters.add", json!({"name":"turn","expression":"90 deg"})).unwrap();
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:X", "tall + 5 mm");
replace(&mut h, "dialog:Height", "tall / 2");
replace(&mut h, "dialog:Angle", "turn");
ok(&mut h);
assert!(!editing(&h.app), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
let sid = h.app.session.active_sketch.unwrap();
let LinkSource::Text { at, height, angle, exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else {
panic!("text source")
};
assert_eq!(
(exprs.x.as_deref(), exprs.y.as_deref(), exprs.height.as_deref(), exprs.angle.as_deref()),
(Some("tall + 5 mm"), None, Some("tall / 2"), Some("turn"))
);
assert!((at.x - 25.0).abs() < 1e-9 && (height - 10.0).abs() < 1e-9 && (angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
// Opened again, the fields show the expressions; a number typed in one of the others
// leaves them alone.
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
assert_eq!(tx(&h.app).values[0], "tall + 5 mm");
assert_eq!((tx(&h.app).values[2].as_str(), tx(&h.app).values[3].as_str()), ("tall / 2", "turn"));
replace(&mut h, "dialog:Y", "7");
ok(&mut h);
let exprs = exprs_of(&h, &link);
assert_eq!(
(exprs.x.as_deref(), exprs.y.as_deref(), exprs.height.as_deref(), exprs.angle.as_deref()),
(Some("tall + 5 mm"), None, Some("tall / 2"), Some("turn"))
);
// A parameter changes while the saved numbers are not yet worked out again: OK on an
// untouched dialog keeps the expressions, and the numbers follow the parameter.
h.app.run("parameters.change", json!({"name":"tall","expression":"40 mm"})).unwrap();
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
ok(&mut h);
let LinkSource::Text { at, height, exprs, .. } = h.app.session.doc.sketch(sid).unwrap().link(&link).unwrap().source.clone() else {
panic!("text source")
};
assert_eq!((exprs.x.as_deref(), exprs.height.as_deref()), (Some("tall + 5 mm"), Some("tall / 2")));
assert!((at.x - 45.0).abs() < 1e-9 && (at.y - 7.0).abs() < 1e-9 && (height - 20.0).abs() < 1e-9, "{at:?} {height}");
// A number typed over an expression replaces it.
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Height", "6");
ok(&mut h);
let exprs = exprs_of(&h, &link);
assert_eq!((exprs.height, exprs.x.as_deref()), (None, Some("tall + 5 mm")));
// A name that is no parameter is an error in the dialog; nothing changes.
select_text(&mut h, &link);
h.app.start("sketch.edit_text");
h.frames(3);
replace(&mut h, "dialog:Height", "nope * 2");
h.frames(2);
ok(&mut h);
assert!(editing(&h.app), "the dialog stays open on a field that cannot be used");
}
#[test]
fn fields_take_expressions_with_units_and_parameters() {
let (mut h, link, _) = setup();
+40 -2
View File
@@ -245,10 +245,13 @@ pub(crate) fn drag_to(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) {
let Some(d) = DRAG.with(|d| d.borrow().clone()) else { return };
let Some(to) = on_plane(app, proj, pos) else { return };
let Some((at, height, angle)) = follow(&d, to, shift) else { return };
let mut params = json!({"link": d.start.link, "at": [at.x, at.y], "height": height, "live": true});
// A resize keeps the exact saved angle.
let mut params = json!({"link": d.start.link, "at": [at.x, at.y], "live": true});
// A resize keeps the exact saved angle; a rotation keeps the height (and the expression
// behind it, if it has one).
if d.grab == Grab::Rotate {
params["angle"] = json!(angle.to_degrees());
} else {
params["height"] = json!(height);
}
if app.session.execute("sketch.edit_text", &params).is_ok() {
app.session.undo.truncate(d.depth + 1);
@@ -449,6 +452,41 @@ mod tests {
assert_eq!(h.app.session.doc.to_json(), before.0);
}
#[test]
fn turning_a_text_keeps_the_expression_of_its_height_and_resizing_replaces_it() {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("parameters.add", json!({"name":"tall","expression":"10 mm"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let result = h.app.run("sketch.text", json!({"text":"II","at":[0,0],"height":"tall"})).unwrap();
let link = result["link"].as_str().unwrap().to_owned();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 60.0;
h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }];
h.frames(3);
let height_expr = |h: &Harness| {
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let LinkSource::Text { exprs, .. } = &sk.link(&link).unwrap().source else { panic!("text") };
exprs.height.clone()
};
assert_eq!(height_expr(&h).as_deref(), Some("tall"));
let f = frame(&h.app, &link).unwrap();
let center = f.to_sketch(f.center());
let to = center + rotate(Vec2::new(0.0, 15.0), 40_f64.to_radians());
h.step(&json!({"drag":[{"handle":"text_rotate"},{"sketch":[to.x,to.y]}],"steps":6})).unwrap();
h.frames(2);
let (_, height, angle) = source(&h, &link);
assert!(angle.abs() > 0.5 && height == 10.0, "turned: {angle} {height}");
assert_eq!(height_expr(&h).as_deref(), Some("tall"), "turning leaves the height alone");
let f = frame(&h.app, &link).unwrap();
let pivot = f.to_sketch(f.min);
let target = pivot + (f.to_sketch(f.max) - pivot) * 2.0;
h.step(&json!({"drag":[{"handle":"text_corner_2"},{"sketch":[target.x,target.y]}],"steps":6})).unwrap();
h.frames(2);
assert!((source(&h, &link).1 - 20.0).abs() < 0.5);
assert_eq!(height_expr(&h), None, "a height set by hand is a number");
}
#[test]
fn the_rotation_knob_turns_the_text_in_steps_with_shift() {
let (mut h, link) = setup();