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:
1 parent
e20cf1858a
commit
b6806b16cf
23 files changed
+751
-70
No files matched your search
@@ -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(¤t) {
|
||||
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();
|
||||
|
||||
@@ -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", ¶ms).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();
|
||||
|
||||
Reference in new issue
Block a user