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 <[email protected]>
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
View File
@@ -235,6 +235,9 @@ impl crate::Feature {
mapped_name(n, parameters);
}
}
for e in sketch.text_exprs_mut() {
*e = clone_expression(e, parameters);
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
+34
View File
@@ -2792,6 +2792,7 @@ impl Document {
}
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
used.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
used.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
}
for p in &self.params {
@@ -2990,6 +2991,39 @@ impl Document {
Ok(())
}
/// Give the texts of a sketch the numbers their expressions come to (in place): a text's
/// height, place and angle that were typed as expressions. A text's outlines are rebuilt from
/// its numbers when the sketch's links are refreshed, so this comes before that. A number
/// typed as a number is left as it is.
pub fn apply_text_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
use crate::eval::val;
for link in &mut sk.links {
let solidcraft_sketch::LinkSource::Text { at, height, angle, exprs, .. } = &mut link.source else { continue };
let number = |e: &Option<String>, kind: Kind, what: &str| -> Result<Option<f64>> {
match e {
None => Ok(None),
Some(e) => val(vals, e, kind).map(Some).map_err(|error| DocError::Expr(format!("{what}: {error}"))),
}
};
if let Some(v) = number(&exprs.x, Kind::Length, "text X")? {
at.x = v;
}
if let Some(v) = number(&exprs.y, Kind::Length, "text Y")? {
at.y = v;
}
if let Some(v) = number(&exprs.height, Kind::Length, "text height")? {
*height = v;
}
if let Some(v) = number(&exprs.angle, Kind::Angle, "text angle")? {
*angle = v;
}
if exprs.height.is_some() && !(*height > 1e-6 && *height < 1e5) {
return Err(DocError::Invalid("a text's height must be positive".into()));
}
}
Ok(())
}
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
/// jump it reaches by stepping the changed values from there, and an edit that would turn
+7 -2
View File
@@ -737,11 +737,13 @@ pub struct Model {
pub no_cache: bool,
}
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
/// sketch's texts), sorted.
fn param_names(f: &Feature) -> Vec<String> {
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
names.sort();
names.dedup();
@@ -1307,7 +1309,7 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
}
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
@@ -4082,6 +4084,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
+5
View File
@@ -217,6 +217,11 @@
//! `crown` (0 by default, and then not saved: a bump that vanishes on the loop, so the face
//! still meets the picked edges). Version 21 designs need no transformation; 0.12.0 asks for an
//! update instead of meeting a feature it can't read or dropping a patch's crown.
//! Also under 22, a sketch text's link source can say that its place, height or angle are
//! expressions (`exprs`: `x`, `y`, `height`, `angle`, each an expression over the design's
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
use serde_json::Value;
+14
View File
@@ -584,6 +584,11 @@ impl Document {
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
}
}
for (what, e) in sketch.text_exprs() {
if expr::references(e).iter().any(|r| r == name) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
}
}
}
}
out
@@ -662,6 +667,13 @@ impl Document {
n += 1;
}
}
for e in sketch.text_exprs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
}
}
if self.favorites.remove(old) {
@@ -717,6 +729,7 @@ impl Document {
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
}
@@ -744,6 +757,7 @@ impl Document {
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
}
if hit {
out.push(f.id);
+1 -1
View File
@@ -255,7 +255,7 @@ 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, style } if style.path.is_none() => {
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`).
+111 -31
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextStyle};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextExprs, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
@@ -21,6 +21,10 @@ use crate::{Result, Session};
#[path = "sketch_text_move_tests.rs"]
mod text_move_tests;
#[cfg(test)]
#[path = "sketch_text_expr_tests.rs"]
mod text_expr_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
@@ -91,7 +95,8 @@ 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; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
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!()
)),
@@ -99,7 +104,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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), ",
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg (numbers or expressions as in sketch.text; a number replaces an expression, and so does moving the text), 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!()
@@ -146,19 +151,47 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
/// What a text's `at` must be.
const AT_MUST: &str = "`at` must be the baseline start [x, y]";
const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
/// The number given for `key`: `None` when it is left out or null, and an error when it is
/// anything but a number (not read as left out).
fn given_num(p: &Value, cmd: &str, key: &str, what: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => num(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be {what}"))),
/// One number of a text, from a command's parameter `key`. A number is that number (mm, or
/// degrees for the angle). A string is an expression over the design's parameters: a bare number
/// in it is in the design's units (degrees for the angle), and it is kept as an expression when
/// it names a parameter, so that the text follows it; one that names none is the number it comes
/// to. Returns the value (mm or radians) and the expression kept. Anything else is refused with
/// `must`.
fn text_number(doc: &solidcraft_doc::Document, v: &Value, cmd: &str, key: &str, must: &str, angle: bool) -> Result<(f64, Option<String>)> {
use solidcraft_doc::expr::{self, Kind};
let kind = if angle { Kind::Angle } else { Kind::Length };
match v {
Value::Number(n) => {
let x = n.as_f64().and_then(crate::params::finite).ok_or_else(|| bad(cmd, must))?;
Ok((if angle { x.to_radians() } else { x }, None))
}
Value::String(e) if !e.trim().is_empty() && e.len() <= 4096 => {
let e = doc.with_design_unit(e.trim(), kind);
match doc.eval(&e, kind) {
Ok(x) => {
let x = crate::params::finite(x).ok_or_else(|| bad(cmd, must))?;
Ok((x, (!expr::references(&e).is_empty()).then_some(e)))
}
// Say what is wrong with it (an unknown parameter, a length where an angle goes).
Err(error) => Err(bad(cmd, format!("{key}: {error}"))),
}
}
_ => Err(bad(cmd, must)),
}
}
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()));
/// A text's values as `text_source` works on them: text, place, height, angle (radians), font,
/// style and the expressions behind the numbers.
type TextValues = (String, Vec2, f64, f64, Option<String>, TextStyle, TextExprs);
/// A text's source from a command's parameters: what is given replaces what `old` has (the
/// defaults, for a new text); what is left out stays, an expression with its number. A number
/// replaces an expression. An input that is not understood is refused, not ignored.
fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option<TextValues>) -> Result<LinkSource> {
let (t0, mut at, mut height, mut angle, f0, s0, mut exprs) =
old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default(), TextExprs::default()));
let text = match p.get("text") {
None | Some(Value::Null) => t0,
Some(Value::String(t)) => t.clone(),
@@ -170,15 +203,27 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
if text.chars().count() > solidcraft_sketch::MAX_TEXT_CHARS {
return Err(bad(cmd, "text too long"));
}
let at = match p.get("at") {
None | Some(Value::Null) => at0,
Some(v) => crate::params::vec2(v).ok_or_else(|| bad(cmd, AT_MUST))?,
};
let height = given_num(p, cmd, "height", "a length in mm")?.unwrap_or(h0);
match p.get("at") {
None | Some(Value::Null) => {}
Some(Value::Array(a)) => {
let [x, y] = a.as_slice() else { return Err(bad(cmd, AT_MUST)) };
(at.x, exprs.x) = text_number(doc, x, cmd, "`at` x", AT_MUST, false)?;
(at.y, exprs.y) = text_number(doc, y, cmd, "`at` y", AT_MUST, false)?;
}
Some(v) => {
at = crate::params::vec2(v).ok_or_else(|| bad(cmd, AT_MUST))?;
(exprs.x, exprs.y) = (None, None);
}
}
if let Some(v) = p.get("height").filter(|v| !v.is_null()) {
(height, exprs.height) = text_number(doc, v, cmd, "`height`", "`height` must be a length in mm, or an expression", false)?;
}
if !(height > 1e-6 && height < 1e5) {
return Err(bad(cmd, "`height` must be positive"));
}
let angle = given_num(p, cmd, "angle", "an angle in degrees")?.map_or(a0, f64::to_radians);
if let Some(v) = p.get("angle").filter(|v| !v.is_null()) {
(angle, exprs.angle) = text_number(doc, v, cmd, "`angle`", "`angle` must be an angle in degrees, or an expression", true)?;
}
let font = match p.get("font") {
None | Some(Value::Null) => f0,
Some(Value::String(f)) if f.trim().is_empty() => None,
@@ -189,7 +234,19 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option
// An unknown font, an unreadable file or a style the font lacks is an error now, not a lost
// link later; so is a font file outside an agent's folders (the font reader asks `vfs`).
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 })
Ok(LinkSource::Text { text, at, height, angle, font, style, exprs })
}
/// The expressions behind a text's numbers, for a command's reply (`{"height": "plate_h / 10"}`).
fn text_exprs_json(src: &LinkSource) -> Option<Value> {
let LinkSource::Text { exprs, .. } = src else { return None };
let mut out = serde_json::Map::new();
for (key, e) in [("x", &exprs.x), ("y", &exprs.y), ("height", &exprs.height), ("angle", &exprs.angle)] {
if let Some(e) = e {
out.insert(key.into(), json!(e));
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -197,7 +254,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("at").is_none_or(Value::is_null) {
return Err(bad(cmd, AT_MUST));
}
let src = text_source(p, cmd, None)?;
let src = text_source(&s.doc, p, cmd, None)?;
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();
@@ -207,11 +264,16 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(&mut sk, &src);
let exprs = text_exprs_json(&src);
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;
Ok(json!({"link": l, "curves": curves, "guide": guide}))
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
}
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
@@ -242,7 +304,7 @@ 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, style }) = sk.link(link).map(|l| l.source.clone()) else {
let Some(LinkSource::Text { text, at, height, angle, font, style, mut exprs }) = 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() {
@@ -250,10 +312,20 @@ pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation:
}
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 moved = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
// What the move changes is a number now, not the expression it was; the rest stays.
if (moved.x - at.x).abs() > 1e-12 {
exprs.x = None;
}
if (moved.y - at.y).abs() > 1e-12 {
exprs.y = None;
}
if rotation.abs() > 1e-12 {
exprs.angle = None;
}
let (at, angle) = (moved, angle + rotation);
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 source = LinkSource::Text { text, at, height, angle, font, style, exprs };
let link = if copy {
sk.add_link(LinkKind::Text, source, &geom)?
} else {
@@ -271,7 +343,10 @@ fn edit_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 out = apply_text_edit(&mut sk, p)?;
// The numbers its expressions give now (a parameter may have changed since the sketch was
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
let _ = doc.apply_text_values(&doc.param_values().0, &mut sk);
let out = apply_text_edit(&mut sk, &doc, p)?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(out)
@@ -282,13 +357,13 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let (out, report) = super::sketch::edit_solved(s, id, false, |sk, _| Ok((apply_text_edit(sk, p)?, super::sketch::Left::Unsolved)))?;
let (out, report) = super::sketch::edit_solved(s, id, false, |sk, doc| Ok((apply_text_edit(sk, doc, p)?, super::sketch::Left::Unsolved)))?;
Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report })))
}
/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines
/// regenerated from them.
fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result<Value> {
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let l = match (str_(p, "link"), str_(p, "entity")) {
(Some(l), _) => l.to_string(),
@@ -296,19 +371,24 @@ 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, style })) => (text, at, height, angle, font, style),
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font, style, exprs })) => (text, at, height, angle, font, style, exprs),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
let src = text_source(doc, p, cmd, Some(old))?;
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 guide = guide(sk, &src);
let exprs = text_exprs_json(&src);
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
Ok(json!({"link": l, "curves": curves, "guide": guide}))
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
}
/// The curve ending at point `pi` and the direction leaving it there.
+1 -1
View File
@@ -141,7 +141,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
id,
&mut sk,
LinkKind::Text,
LinkSource::Text { text, at, height, angle, font: None, style: Default::default() },
LinkSource::Text { text, at, height, angle, font: None, style: Default::default(), exprs: Default::default() },
)
.is_ok()
{
+3
View File
@@ -180,6 +180,9 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
}
let doc = placed_candidate(s, doc);
let (vals, _) = doc.param_values();
// A text's outlines are rebuilt from its numbers: the ones its expressions give come first
// (an expression that cannot be evaluated leaves the text as it was).
let _ = doc.apply_text_values(&vals, sk);
let st = s.model.state_before(id);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
@@ -0,0 +1,291 @@
//! A sketch text's height, place and angle can be expressions of the design's parameters: the
//! text keeps the expression, follows the parameters, and a number typed instead replaces it.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkSource;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Lower-left and upper-right corners of the outlines of the text in sketch `S`.
fn text_box(s: &Session) -> (Vec2, Vec2) {
let id = s.doc.find_feature("S").unwrap().id;
let st = s.model.state();
let sk = &st.sketch(id).unwrap().sketch;
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for q in sk.points.iter().filter(|q| q.link.is_some()) {
lo = Vec2::new(lo.x.min(q.pos.x), lo.y.min(q.pos.y));
hi = Vec2::new(hi.x.max(q.pos.x), hi.y.max(q.pos.y));
}
(lo, hi)
}
fn no_trouble(s: &Session) {
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
}
/// The numbers and expressions stored for the text `link` of sketch `S`.
fn stored(s: &Session, link: &str) -> (Vec2, f64, f64, solidcraft_sketch::TextExprs) {
let id = s.doc.find_feature("S").unwrap().id;
let LinkSource::Text { at, height, angle, exprs, .. } = &s.doc.sketch(id).unwrap().link(link).unwrap().source else { panic!("not a text") };
(*at, *height, *angle, exprs.clone())
}
fn reopened(s: &Session) -> Session {
Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap())
}
#[test]
fn a_texts_height_and_place_typed_as_expressions_follow_their_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "20 mm"}));
run(&mut s, "parameters.add", json!({"name": "left", "expression": "30 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
// Capital letters without descenders: the outlines stand on the baseline, one height tall.
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": ["left", 25], "height": "tall"}));
let link = r["link"].as_str().unwrap().to_string();
assert_eq!(r["exprs"], json!({"x": "left", "height": "tall"}), "{r}");
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!((lo.y - 25.0).abs() < 1e-9 && (hi.y - 45.0).abs() < 1e-6, "{lo:?} {hi:?}");
// The first letter's side bearing is a fixed share of the height.
let bearing = (lo.x - 30.0) / 20.0;
assert!(bearing > 0.0 && bearing < 0.5, "{lo:?}");
// Smaller and further left: the outlines follow both parameters, and so do they after the
// design is saved and opened again.
run(&mut s, "parameters.change", json!({"name": "tall", "expression": "8 mm"}));
run(&mut s, "parameters.change", json!({"name": "left", "expression": "5 mm"}));
let check = |s: &Session, left: f64, tall: f64| {
no_trouble(s);
let (lo, hi) = text_box(s);
assert!((lo.y - 25.0).abs() < 1e-9 && (hi.y - 25.0 - tall).abs() < 1e-6, "{lo:?} {hi:?}");
assert!((lo.x - left - bearing * tall).abs() < 1e-6, "{lo:?}");
};
check(&s, 5.0, 8.0);
let json = s.doc.to_json();
assert!(json.contains("\"exprs\""), "the design file keeps the expressions");
check(&reopened(&s), 5.0, 8.0);
let (_, _, _, exprs) = stored(&s, &link);
assert_eq!((exprs.x.as_deref(), exprs.y, exprs.height.as_deref(), exprs.angle), (Some("left"), None, Some("tall"), None));
// Editing the sketch afterwards keeps the text where its parameters put it.
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
run(&mut s, "sketch.edit_text", json!({"link": link, "text": "LIVE"}));
run(&mut s, "sketch.finish", json!({}));
check(&s, 5.0, 8.0);
let (at, height, _, exprs) = stored(&s, &link);
assert!((at.x - 5.0).abs() < 1e-9 && (height - 8.0).abs() < 1e-9 && exprs.height.as_deref() == Some("tall"), "{at:?} {height} {exprs:?}");
}
#[test]
fn a_texts_angle_can_be_an_expression_too() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [0, 0], "height": 10, "angle": "turn"}));
let link = r["link"].as_str().unwrap().to_string();
assert_eq!(r["exprs"], json!({"angle": "turn"}), "{r}");
run(&mut s, "sketch.finish", json!({}));
let (lo, hi) = text_box(&s);
assert!(hi.x - lo.x > hi.y - lo.y, "along the x axis: {lo:?} {hi:?}");
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "90 deg"}));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!(hi.y - lo.y > hi.x - lo.x && hi.x.abs() < 1e-9 && lo.x < -9.0, "upright, the letters run up the y axis: {lo:?} {hi:?}");
assert_eq!(stored(&s, &link).3.angle.as_deref(), Some("turn"));
// Reopened, the text stands as the parameter says.
let (lo, hi) = text_box(&reopened(&s));
assert!(hi.y - lo.y > hi.x - lo.x, "{lo:?} {hi:?}");
}
#[test]
fn the_parameters_a_text_uses_are_listed_renamed_and_kept() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "12 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [0, 0], "height": "tall * 2"}));
let link = r["link"].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
let users = run(&mut s, "parameters.users", json!({"name": "tall"}));
assert!(users.to_string().contains("Text height"), "{users}");
assert!(users["drives"].to_string().contains("\"S\""), "{users}");
let e = s.execute("parameters.delete", &json!({"name": "tall"})).unwrap_err().to_string();
assert!(e.contains("Text height"), "{e}");
run(&mut s, "parameters.rename", json!({"name": "tall", "new_name": "big"}));
assert_eq!(stored(&s, &link).3.height.as_deref(), Some("big * 2"));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!((hi.y - lo.y - 24.0).abs() < 1e-6, "{lo:?} {hi:?}");
run(&mut s, "parameters.change", json!({"name": "big", "expression": "5 mm"}));
let (lo, hi) = text_box(&s);
assert!((hi.y - lo.y - 10.0).abs() < 1e-6, "{lo:?} {hi:?}");
// The edges of the dependency graph name it, too.
let graph = run(&mut s, "parameters.graph", json!({}));
assert!(graph["edges"].to_string().contains("big"), "{graph}");
}
#[test]
fn a_number_replaces_an_expression_and_plain_numbers_save_as_before() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "20 mm"}));
run(&mut s, "parameters.add", json!({"name": "left", "expression": "30 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
// Numbers, and expressions naming no parameter, are numbers: nothing new is saved.
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [3, "2 mm + 2 mm"], "height": "10 mm", "angle": "30 deg"}));
let plain = r["link"].as_str().unwrap().to_string();
assert!(r.get("exprs").is_none(), "{r}");
let (at, height, angle, exprs) = stored(&s, &plain);
assert!(at.dist(Vec2::new(3.0, 4.0)) < 1e-12 && (height - 10.0).abs() < 1e-12 && (angle - 30f64.to_radians()).abs() < 1e-12, "{at:?}");
assert!(exprs.is_empty());
assert!(!s.doc.to_json().contains("exprs"), "a design without expressions saves as before");
let r = run(&mut s, "sketch.text", json!({"text": "LIVE", "at": ["left", "left / 2"], "height": "tall"}));
let link = r["link"].as_str().unwrap().to_string();
// What an edit leaves out stays an expression; a number given replaces what it was.
run(&mut s, "sketch.edit_text", json!({"link": link, "text": "LIVE!"}));
assert_eq!(stored(&s, &link).3.height.as_deref(), Some("tall"));
let r = run(&mut s, "sketch.edit_text", json!({"link": link, "height": 12}));
assert_eq!(r["exprs"], json!({"x": "left", "y": "left / 2"}), "{r}");
let (at, height, _, exprs) = stored(&s, &link);
assert!((height - 12.0).abs() < 1e-12 && exprs.height.is_none() && at.dist(Vec2::new(30.0, 15.0)) < 1e-9, "{at:?} {height} {exprs:?}");
run(&mut s, "sketch.edit_text", json!({"link": link, "at": [7, "left"]}));
assert_eq!(run(&mut s, "sketch.edit_text", json!({"link": link}))["exprs"], json!({"y": "left"}));
// Moving the text turns what moves into numbers; the height and the other coordinate stay.
run(&mut s, "sketch.edit_text", json!({"link": link, "at": ["left", "left"], "height": "tall"}));
let curve = s.doc.sketch(s.active_sketch.unwrap()).unwrap().link_curves(&link)[0];
let curve = s.doc.sketch(s.active_sketch.unwrap()).unwrap().curves[curve].id.clone();
run(&mut s, "sketch.move", json!({"entities": [curve], "translate": [10, 0]}));
let (at, _, _, exprs) = stored(&s, &link);
assert!((at.x - 40.0).abs() < 1e-9 && (at.y - 30.0).abs() < 1e-9, "{at:?}");
assert_eq!((exprs.x, exprs.y.as_deref(), exprs.height.as_deref()), (None, Some("left"), Some("tall")));
}
#[test]
fn a_text_expression_that_cannot_be_used_is_refused_and_changes_nothing() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "30 deg"}));
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "20 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [0, 0]}));
let link = r["link"].as_str().unwrap().to_string();
let before = s.doc.to_json();
let undo = s.undo.len();
for (extra, error) in [
(json!({"at": ["nope", 0]}), "`at` x"),
(json!({"at": [0, "turn"]}), "`at` y"),
(json!({"at": [0, "tall +"]}), "`at` y"),
(json!({"height": "nope"}), "unknown parameter"),
(json!({"height": "turn"}), "`height`"),
(json!({"height": "tall - 30 mm"}), "`height` must be positive"),
(json!({"height": "-3 mm"}), "`height` must be positive"),
(json!({"height": "0 * tall"}), "`height` must be positive"),
(json!({"height": ""}), "`height` must be a length"),
(json!({"angle": "tall"}), "`angle`"),
(json!({"angle": "5 mm"}), "`angle`"),
(json!({"angle": "nope"}), "unknown parameter"),
] {
let mut create = json!({"text": "A", "at": [0, 0]});
let mut edit = json!({"link": link});
for (key, value) in extra.as_object().unwrap() {
create[key] = value.clone();
edit[key] = value.clone();
}
let e = s.execute("sketch.text", &create).unwrap_err().to_string();
assert!(e.contains(error), "{create}: {e}");
for live in [false, true] {
edit["live"] = json!(live);
let e = s.execute("sketch.edit_text", &edit).unwrap_err().to_string();
assert!(e.contains(error), "{edit}: {e}");
}
assert_eq!(s.doc.to_json(), before, "{extra}");
assert_eq!(s.undo.len(), undo, "{extra}");
}
}
#[test]
fn a_parameter_that_leaves_a_text_no_height_is_an_error_of_its_sketch_not_a_crash() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "tall", "expression": "12 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [0, 0], "height": "tall"}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
run(&mut s, "parameters.change", json!({"name": "tall", "expression": "0 mm"}));
let sketch = s.model.results.iter().find(|r| r.name == "S").unwrap();
assert!(sketch.error.as_deref().is_some_and(|e| e.contains("height")), "{:?}", sketch.error);
run(&mut s, "parameters.change", json!({"name": "tall", "expression": "6 mm"}));
no_trouble(&s);
let (lo, hi) = text_box(&s);
assert!((hi.y - lo.y - 6.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
/// The height of the outlines of the texts in the sketch feature `id`.
fn outline_height(s: &Session, id: u64) -> f64 {
let st = s.model.state();
let sk = &st.sketch(id).unwrap().sketch;
let ys: Vec<f64> = sk.points.iter().filter(|q| q.link.is_some()).map(|q| q.pos.y).collect();
ys.iter().cloned().fold(f64::MIN, f64::max) - ys.iter().cloned().fold(f64::MAX, f64::min)
}
/// The first dimension's parameter of the sketch feature `id` and the expression of its text's height.
fn dimension_and_expression(s: &Session, id: u64) -> (String, Option<String>) {
let sk = s.doc.sketch(id).unwrap();
let dimension = sk.constraints.iter().find_map(|c| c.param.clone()).expect("a dimension with a parameter");
let LinkSource::Text { exprs, .. } = &sk.links.iter().find(|l| matches!(l.source, LinkSource::Text { .. })).unwrap().source else {
panic!("not a text")
};
(dimension, exprs.height.clone())
}
#[test]
fn a_cloned_components_text_follows_the_cloned_dimension_it_names_not_the_original() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 20]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 60}));
let original = s.doc.find_feature("S").unwrap().id;
let own = s.doc.sketch(original).unwrap().constraints.iter().find_map(|c| c.param.clone()).expect("a dimension with a parameter");
// The text's height is a quarter of the sketch's own dimension.
run(&mut s, "sketch.text", json!({"text": "LIVE", "at": [5, 5], "height": format!("{own} / 4")}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
assert!((outline_height(&s, original) - 15.0).abs() < 1e-6);
let copy = run(&mut s, "component.paste_new", json!({"component": part, "translate": [0, 50, 0]}))["component"].as_u64().unwrap();
let clone = s.doc.features.iter().find(|f| f.component == copy && matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
let (theirs, expression) = dimension_and_expression(&s, clone);
assert_ne!(theirs, own, "the copy's dimension is its own");
assert_eq!(expression, Some(format!("{theirs} / 4")), "and so is the expression that uses it");
no_trouble(&s);
assert!((outline_height(&s, clone) - 15.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": theirs, "expression": "40"}));
assert!((outline_height(&s, clone) - 10.0).abs() < 1e-6, "the copy follows its own dimension");
assert!((outline_height(&s, original) - 15.0).abs() < 1e-6, "and the original stays");
no_trouble(&s);
}
#[test]
fn bare_numbers_in_a_text_expression_are_in_the_designs_units() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
s.doc_mut().units = "in".into();
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [0, 0], "height": "1"}));
let (_, height, _, exprs) = stored(&s, r["link"].as_str().unwrap());
// Numbers stay millimetres for programmatic callers; an expression is in the design's units.
assert!((height - 25.4).abs() < 1e-9 && exprs.is_empty(), "{height}");
let r = run(&mut s, "sketch.text", json!({"text": "A", "at": [0, 0], "height": 2}));
assert!((stored(&s, r["link"].as_str().unwrap()).1 - 2.0).abs() < 1e-12);
}
@@ -234,16 +234,17 @@ const TEXT: &str = "`text` must be a string";
/// "H" drew the text 5 mm high, `at: ["H", 25]` put it at the origin. Each with its message.
fn not_understood() -> Vec<(Value, &'static str)> {
vec![
(json!({"height":"H"}), HEIGHT),
// A string is an expression now: one naming no parameter is refused as such.
(json!({"height":"H"}), "unknown parameter `H`"),
(json!({"height":true}), HEIGHT),
(json!({"height":[10]}), HEIGHT),
(json!({"height":1e10}), HEIGHT),
(json!({"height":0}), "`height` must be positive"),
(json!({"height":-3}), "`height` must be positive"),
(json!({"angle":"A"}), ANGLE),
(json!({"angle":"A"}), "unknown parameter `A`"),
(json!({"angle":[15]}), ANGLE),
(json!({"angle":1e10}), ANGLE),
(json!({"at":["H",25]}), AT),
(json!({"at":["H",25]}), "unknown parameter `H`"),
(json!({"at":[1]}), AT),
(json!({"at":[1,2,3]}), AT),
(json!({"at":[1,null]}), AT),
+9 -1
View File
@@ -25,7 +25,15 @@ 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, style: Default::default() },
LinkSource::Text {
text: "A".into(),
at: Vec2::ZERO,
height: 2.,
angle: 0.,
font: None,
style: Default::default(),
exprs: Default::default(),
},
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
+9 -1
View File
@@ -68,7 +68,15 @@ fn each_sketch_state_takes_its_palette_colour() {
let lost_link = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 6.), Vec2::new(2., 6.))]).unwrap();
sk.set_link_lost(&lost_link, true);
let lost = sk.curves.len() - 1;
let text = LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None, style: Default::default() };
let text = LinkSource::Text {
text: "A".into(),
at: Vec2::ZERO,
height: 2.,
angle: 0.,
font: None,
style: Default::default(),
exprs: Default::default(),
};
sk.add_link(LinkKind::Text, text, &[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))]).unwrap();
let text = sk.curves.len() - 1;
let mut flags = vec![false; sk.curves.len()];
+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");
+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();
+1 -1
View File
@@ -75,7 +75,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 21, including design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
Current designs use format 21, including design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, the expressions behind a sketch text's height, place and angle, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
+12 -4
View File
@@ -47,16 +47,24 @@ circle and update the outline when the inline **Sides** value changes.
`sketch.edit_text` take a `font`, the family or full name of an installed font ("DejaVu Sans") or the path
of a `.ttf` / `.otf` file, and the **Edit Text** dialog has a **Font** field for it. An unknown font is an
error, and the font is kept with the text in the design, so the text stays editable in it. `SOLIDCRAFT_FONT_DIRS`
adds folders to the ones searched for installed fonts. The same two commands refuse a `text` that is not a
string, an `at` that is not two numbers, and a `height` or `angle` that is not a number (or a `height` that is not
positive) with a message, instead of drawing the text with a default; left out, they keep their defaults.
adds folders to the ones searched for installed fonts. A text's `height`, its `angle` and each coordinate of
its `at` can be expressions of the design's parameters, given as strings (`"height": "plate_height / 10"`,
`"at": ["plate_width / 2", 5]`): the text keeps the expression and follows the parameters when they change, and
saves it with the design. A number takes the place of an expression, and so does moving the text or dragging its
handles (what moves becomes a number; turning a text leaves its height as it was). A string that names no
parameter is the number it comes to. The same two commands refuse a `text` that is not a string, an `at` that is
not two numbers or expressions, a `height` or `angle` that is neither (or a `height` that is not positive) and an
expression that cannot be worked out with a message, instead of drawing the text with a default; left out, they
keep their defaults.
Right-click a letter's filled interior or outline and choose **Edit Text**. You can also
start **Edit Text** first and click the letter. All outlines belonging to that text are
highlighted while the dialog is open. A profile bounded by multiple text sources or other
geometry cannot identify one text: use **Select Other** to choose its outline instead.
Change the text, font, baseline X/Y, height or angle, then confirm with **OK**; **Cancel**
keeps the original text. Position and height use the document's units.
keeps the original text. Position and height use the document's units. The four fields take
expressions: one that names a parameter (`plate_width / 2`) stays an expression, is shown as typed
when the dialog is opened again, and keeps the text following the parameter.
A text can follow a curve of its sketch (its **Path**). That curve becomes construction: the
letters stand on it and their descenders cross it, so as a normal curve it would cut them into