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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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