mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
The options at MTEXT's "Specify opposite corner" prompt printed "not available yet". Each now asks its question and returns to the corner prompt: Height (also sets TEXTSIZE), Justify (TL..BR, the insertion point is the box's attachment point), Line spacing (At least/Exactly, then a factor such as 1.5x or a distance), Rotation (picked, or typed through the drawing's angle settings; the box turns with it), Style (? lists them), Width (replaces the opposite corner) and Columns (No; Dynamic and Static say columns are not available yet). MText stores the line spacing style (DXF group 73) so Exactly round-trips. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ee34f423cc
commit
3ffe1ca2af
@@ -252,6 +252,10 @@ pub struct MText {
|
||||
pub contents: String,
|
||||
#[serde(default = "one")]
|
||||
pub line_spacing: f64,
|
||||
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
|
||||
/// heights; At least (`false`, the default) lets taller characters push lines apart.
|
||||
#[serde(default)]
|
||||
pub line_spacing_exact: bool,
|
||||
}
|
||||
fn attach_tl() -> u8 {
|
||||
1
|
||||
|
||||
@@ -385,6 +385,7 @@ fn mtext_kind(contents: &str) -> EntityKind {
|
||||
style: "Standard".into(),
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -485,6 +485,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
style: st.text_style.clone(),
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size * k,
|
||||
@@ -525,6 +526,7 @@ fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
style: "Standard".into(),
|
||||
contents: t.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}))?
|
||||
.hex(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, Vec3, arc_to_bulge, bulge_to_arc};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::*;
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("mtext", "Multiline Text", run_mtext)
|
||||
.menu(&["Draw", "Text", "Multiline Text..."])
|
||||
.alias(&["t", "mt"])
|
||||
.params("{at, text, height?, width?, attach?: 1..9, rotation?}")
|
||||
.params("{at, text, height?, width?, attach?: 1..9 | justify?: TL..BR, rotation?, style?, lineSpacing?, lineSpacingExact?}")
|
||||
.interactive(|_| Ok(Box::new(MTextM::default()))),
|
||||
]
|
||||
}
|
||||
@@ -352,16 +352,24 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if height <= 0.0 {
|
||||
return Err(bad("mtext", "height must be positive"));
|
||||
}
|
||||
let style = s.doc()?.header.str("TEXTSTYLE", "Standard");
|
||||
let style = match str_param(p, "style") {
|
||||
Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?,
|
||||
None => s.doc()?.header.str("TEXTSTYLE", "Standard"),
|
||||
};
|
||||
let attach = match str_param(p, "justify") {
|
||||
Some(j) => super::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?,
|
||||
None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
|
||||
};
|
||||
let k = EntityKind::MText(MText {
|
||||
insert: v3(at),
|
||||
height,
|
||||
width: f64_or(p, "width", 0.0).max(0.0),
|
||||
attach: p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
|
||||
attach,
|
||||
rotation: f64_or(p, "rotation", 0.0).to_radians(),
|
||||
style,
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: f64_or(p, "lineSpacing", 1.0),
|
||||
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
|
||||
});
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
@@ -1414,16 +1422,35 @@ struct MTextM {
|
||||
/// Paragraphs typed so far: one per input line, an empty line ends the text (as on
|
||||
/// AutoCAD's command line and in scripts).
|
||||
lines: Vec<String>,
|
||||
/// Height, justification, line spacing, rotation, style and width set by the options.
|
||||
opts: super::mtext_opts::MTextOpts,
|
||||
/// An option asking a further question.
|
||||
ask: Option<super::mtext_opts::MTextAsk>,
|
||||
}
|
||||
|
||||
impl MTextM {
|
||||
/// The box is known: the opposite corner was picked or the Width option answered.
|
||||
fn boxed(&self) -> bool {
|
||||
self.second.is_some() || self.opts.width.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MTextM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MTEXT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.opts = super::mtext_opts::MTextOpts::new(s);
|
||||
s.echo(format!("Current text style: \"{}\" Text height: {:.4} Annotative: No", self.opts.style, self.opts.height));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.first, self.second) {
|
||||
(None, _) => Prompt::new("Specify first corner", Accept::POINT),
|
||||
(Some(a), None) => Prompt::new("Specify opposite corner", Accept::POINT)
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
return q.prompt(&self.opts, a);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT)
|
||||
.kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"])
|
||||
.base(a),
|
||||
_ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT),
|
||||
@@ -1431,28 +1458,27 @@ impl Interactive for MTextM {
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.first, self.second, i) {
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
use super::mtext_opts::Outcome;
|
||||
self.ask = match q.input(s, i, &mut self.opts, a)? {
|
||||
Outcome::Ask(next) => Some(next),
|
||||
Outcome::Back | Outcome::Text => None,
|
||||
};
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.first, self.boxed(), i) {
|
||||
(None, _, Input::Point(p)) => self.first = Some(p),
|
||||
(Some(_), None, Input::Point(p)) => self.second = Some(p),
|
||||
(Some(_), Some(_), Input::Text(t)) if !t.is_empty() => self.lines.push(t),
|
||||
(Some(a), Some(b), Input::Text(_) | Input::Enter) => {
|
||||
(Some(_), false, Input::Point(p)) => self.second = Some(p),
|
||||
(Some(_), false, Input::Keyword(k)) => {
|
||||
self.ask =
|
||||
Some(super::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?);
|
||||
}
|
||||
(Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t),
|
||||
(Some(a), true, Input::Text(_) | Input::Enter) => {
|
||||
if self.lines.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let h = d.header.f64("TEXTSIZE", 0.2);
|
||||
let style = d.header.str("TEXTSTYLE", "Standard");
|
||||
let tl = Vec2::new(a.x.min(b.x), a.y.max(b.y));
|
||||
s.add_entity(EntityKind::MText(MText {
|
||||
insert: Vec3::new(tl.x, tl.y, 0.0),
|
||||
height: h,
|
||||
width: (a.x - b.x).abs(),
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style,
|
||||
contents: self.lines.join("\\P"),
|
||||
line_spacing: 1.0,
|
||||
}))?;
|
||||
s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(_, _, Input::Enter) => return Ok(Step::Done),
|
||||
@@ -1461,8 +1487,14 @@ impl Interactive for MTextM {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.first, self.second) {
|
||||
(Some(a), None) => vec![lwpoly(rect_vertices(a, c), true)],
|
||||
match (self.first, self.boxed(), self.ask) {
|
||||
(Some(a), false, None) => {
|
||||
// The box, turned by the rotation about the first corner.
|
||||
let r = self.opts.rotation;
|
||||
let l = (c - a).rotate(-r);
|
||||
let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)];
|
||||
vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ mod layer;
|
||||
mod layout;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
mod pline_opts;
|
||||
mod props;
|
||||
mod qselect;
|
||||
|
||||
@@ -1250,6 +1250,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
style: style.text_style.clone(),
|
||||
contents: g.value.clone(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! MTEXT's options before the text (Height, Justify, Line spacing, Rotation, Style, Width,
|
||||
//! Columns) for the prompt machine in `draw.rs`.
|
||||
|
||||
use cadcraft_doc::MText;
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::curves;
|
||||
use super::machines::number;
|
||||
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// The justification keywords, in attachment order 1..=9.
|
||||
pub(super) const JUSTIFY: [&str; 9] = ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
|
||||
|
||||
/// Attachment code (1..=9) for a justification keyword.
|
||||
pub(super) fn attach_code(k: &str) -> Option<u8> {
|
||||
JUSTIFY.iter().position(|j| j.eq_ignore_ascii_case(k.trim())).map(|i| i as u8 + 1)
|
||||
}
|
||||
|
||||
/// What the options set before the text is typed.
|
||||
pub(super) struct MTextOpts {
|
||||
pub height: f64,
|
||||
pub attach: u8,
|
||||
/// Line spacing factor (1 = 5/3 of the text height between baselines).
|
||||
pub spacing: f64,
|
||||
pub exact: bool,
|
||||
pub rotation: f64,
|
||||
pub style: String,
|
||||
/// The Width option replaces the opposite corner.
|
||||
pub width: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for MTextOpts {
|
||||
fn default() -> Self {
|
||||
MTextOpts { height: 0.2, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style: "Standard".into(), width: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl MTextOpts {
|
||||
pub(super) fn new(s: &Session) -> Self {
|
||||
let (height, style) =
|
||||
s.doc().map(|d| (d.header.f64("TEXTSIZE", 0.2), d.header.str("TEXTSTYLE", "Standard"))).unwrap_or((0.2, "Standard".into()));
|
||||
MTextOpts { height, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style, width: None }
|
||||
}
|
||||
|
||||
/// The text box from the first corner and either the opposite corner or the Width answer:
|
||||
/// the insertion point is the box's attachment point, the box turned by the rotation about
|
||||
/// the first corner.
|
||||
pub(super) fn mtext(&self, first: Vec2, second: Option<Vec2>, contents: String) -> MText {
|
||||
let (insert, width) = match (second, self.width) {
|
||||
(Some(b), _) => {
|
||||
// The opposite corner in the box's own (rotated) frame.
|
||||
let l = (b - first).rotate(-self.rotation);
|
||||
let (x0, x1) = (l.x.min(0.0), l.x.max(0.0));
|
||||
let (y0, y1) = (l.y.min(0.0), l.y.max(0.0));
|
||||
let i = usize::from(self.attach.clamp(1, 9) - 1);
|
||||
let x = [x0, (x0 + x1) / 2.0, x1][i % 3];
|
||||
let y = [y1, (y0 + y1) / 2.0, y0][i / 3];
|
||||
(first + Vec2::new(x, y).rotate(self.rotation), x1 - x0)
|
||||
}
|
||||
(None, w) => (first, w.unwrap_or(0.0)),
|
||||
};
|
||||
MText {
|
||||
insert: Vec3::new(insert.x, insert.y, 0.0),
|
||||
height: self.height,
|
||||
width,
|
||||
attach: self.attach.clamp(1, 9),
|
||||
rotation: self.rotation,
|
||||
style: self.style.clone(),
|
||||
contents,
|
||||
line_spacing: self.spacing,
|
||||
line_spacing_exact: self.exact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A question an option asks before MTEXT goes back to "Specify opposite corner".
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MTextAsk {
|
||||
Height,
|
||||
Justify,
|
||||
SpacingType,
|
||||
SpacingFactor,
|
||||
Rotation,
|
||||
Style,
|
||||
Width,
|
||||
Columns,
|
||||
}
|
||||
|
||||
pub(super) enum Outcome {
|
||||
/// Ask this next (the same question again after an invalid answer).
|
||||
Ask(MTextAsk),
|
||||
/// Back to the opposite-corner prompt.
|
||||
Back,
|
||||
/// The Width answer replaces the opposite corner: go on to the text.
|
||||
Text,
|
||||
}
|
||||
|
||||
impl MTextAsk {
|
||||
pub(super) fn from_keyword(k: &str) -> Option<MTextAsk> {
|
||||
Some(match k {
|
||||
"Height" => MTextAsk::Height,
|
||||
"Justify" => MTextAsk::Justify,
|
||||
"Line spacing" => MTextAsk::SpacingType,
|
||||
"Rotation" => MTextAsk::Rotation,
|
||||
"Style" => MTextAsk::Style,
|
||||
"Width" => MTextAsk::Width,
|
||||
"Columns" => MTextAsk::Columns,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prompt(self, o: &MTextOpts, first: Vec2) -> Prompt {
|
||||
match self {
|
||||
MTextAsk::Height => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{:.4}", o.height)).base(first),
|
||||
MTextAsk::Justify => Prompt::new("Enter justification", curves::KW)
|
||||
.kw(&JUSTIFY)
|
||||
.default(JUSTIFY.get(usize::from(o.attach.clamp(1, 9) - 1)).copied().unwrap_or("TL")),
|
||||
MTextAsk::SpacingType => {
|
||||
Prompt::new("Enter line spacing type", curves::KW).kw(&["At least", "Exactly"]).default(if o.exact { "Exactly" } else { "At least" })
|
||||
}
|
||||
MTextAsk::SpacingFactor => Prompt::new("Enter line spacing factor or distance", Accept::NUMBER).default(format!("{}x", trim(o.spacing))),
|
||||
MTextAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default(trim(o.rotation.to_degrees())).base(first),
|
||||
MTextAsk::Style => Prompt::new("Enter style name", curves::KW).kw(&["?"]).default(o.style.clone()),
|
||||
MTextAsk::Width => Prompt::new("Specify width", Accept::POINT_OR_NUMBER).base(first),
|
||||
MTextAsk::Columns => Prompt::new("Enter column type", curves::KW).kw(&["Dynamic", "Static", "No"]).default("No"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn input(self, s: &mut Session, i: Input, o: &mut MTextOpts, first: Vec2) -> Result<Outcome> {
|
||||
let word = |i: &Input| match i {
|
||||
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_string()),
|
||||
_ => None,
|
||||
};
|
||||
match (self, &i) {
|
||||
(_, Input::Enter) => Ok(if self == MTextAsk::SpacingType { Outcome::Ask(MTextAsk::SpacingFactor) } else { Outcome::Back }),
|
||||
(MTextAsk::Height, Input::Point(p)) => set_height(s, o, first.dist(*p)),
|
||||
(MTextAsk::Height, Input::Text(t)) => set_height(s, o, number(t).unwrap_or(f64::NAN)),
|
||||
(MTextAsk::Justify, _) => {
|
||||
let k = word(&i).unwrap_or_default();
|
||||
o.attach = attach_code(&k).ok_or_else(|| other("Invalid option keyword."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::SpacingType, _) => {
|
||||
let k = word(&i).unwrap_or_default().to_ascii_lowercase();
|
||||
o.exact = match k.as_str() {
|
||||
"at least" | "a" | "at" => false,
|
||||
"exactly" | "e" => true,
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
};
|
||||
Ok(Outcome::Ask(MTextAsk::SpacingFactor))
|
||||
}
|
||||
(MTextAsk::SpacingFactor, Input::Text(t)) => {
|
||||
// "1.5x" is a factor; a plain number is the distance between lines (1x = 5/3 of the height).
|
||||
let t = t.trim();
|
||||
let f = match t.strip_suffix(['x', 'X']) {
|
||||
Some(f) => number(f),
|
||||
None => number(t).map(|d| d / (o.height * 5.0 / 3.0)),
|
||||
};
|
||||
o.spacing = f
|
||||
.filter(|f| (0.25 - 1e-9..=4.0 + 1e-9).contains(f))
|
||||
.ok_or_else(|| other("Requires a factor between 0.25x and 4x, or a distance."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Rotation, Input::Point(p)) => {
|
||||
o.rotation = first.angle_to(*p);
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Rotation, Input::Text(t)) => {
|
||||
o.rotation = s.angle_settings().direction(t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Style, _) => {
|
||||
let name = word(&i).unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
if name == "?" {
|
||||
let names: Vec<String> = d.text_styles.iter().map(|t| t.name.clone()).collect();
|
||||
s.echo(format!("Text styles: {}", names.join(", ")));
|
||||
return Ok(Outcome::Ask(MTextAsk::Style));
|
||||
}
|
||||
o.style = d.text_style(&name).map(|t| t.name.clone()).ok_or_else(|| other(format!("Cannot find text style \"{name}\".")))?;
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
(MTextAsk::Width, Input::Point(p)) => {
|
||||
// The distance along the text direction from the first corner.
|
||||
o.width = Some((*p - first).rotate(-o.rotation).x.abs());
|
||||
Ok(Outcome::Text)
|
||||
}
|
||||
(MTextAsk::Width, Input::Text(t)) => {
|
||||
o.width = Some(number(t).filter(|w| w.is_finite() && *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?);
|
||||
Ok(Outcome::Text)
|
||||
}
|
||||
(MTextAsk::Columns, _) => match word(&i).unwrap_or_default().to_ascii_lowercase().as_str() {
|
||||
"no" | "n" => Ok(Outcome::Back),
|
||||
"dynamic" | "d" | "static" | "s" => {
|
||||
s.echo("Text columns are not available yet; the text stays in one column.");
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
_ => Err(other("Invalid option keyword.")),
|
||||
},
|
||||
_ => Ok(Outcome::Ask(self)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_height(s: &mut Session, o: &mut MTextOpts, h: f64) -> Result<Outcome> {
|
||||
if !h.is_finite() || h <= 0.0 {
|
||||
return Err(other("Requires a positive height."));
|
||||
}
|
||||
o.height = h;
|
||||
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
||||
Ok(Outcome::Back)
|
||||
}
|
||||
|
||||
/// A number without trailing zeros (`1.5`, `2`).
|
||||
fn trim(v: f64) -> String {
|
||||
let s = format!("{v:.4}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub fn bracket() -> Drawing {
|
||||
add(on("Notes"), text(Vec2::new(4.1, -0.3), 0.16, "FRONT VIEW"));
|
||||
add(
|
||||
on("Notes"),
|
||||
EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0 }),
|
||||
EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0, line_spacing_exact: false }),
|
||||
);
|
||||
let tb0 = Vec2::new(14.0, -0.5);
|
||||
add(on("Title"), lwpoly(rect_vertices(tb0, tb0 + Vec2::new(4.0, 2.0)), true));
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! MTEXT's options before the text: Height, Justify, Line spacing, Rotation, Style, Width and
|
||||
//! Columns, from the command line and scripts (#408).
|
||||
|
||||
use cadcraft_doc::{EntityKind, MText, TextStyle};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
fn last_mtext(s: &Session) -> MText {
|
||||
match s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::MText(t)) => t,
|
||||
k => panic!("no mtext: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn logged(s: &Session, text: &str) -> bool {
|
||||
s.log.iter().any(|l| l.contains(text))
|
||||
}
|
||||
|
||||
fn at(t: &MText) -> Vec2 {
|
||||
t.insert.xy()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_justify_and_corners() {
|
||||
let mut s = Session::new();
|
||||
s.script("MTEXT 0,10 h 0.5 j mc 20,0\nHello\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let t = last_mtext(&s);
|
||||
assert_eq!(t.height, 0.5);
|
||||
assert_eq!(t.attach, 5);
|
||||
assert_eq!(t.width, 20.0);
|
||||
assert!(at(&t).near(Vec2::new(10.0, 5.0), 1e-9), "middle centre of the box: {:?}", at(&t));
|
||||
assert_eq!(t.contents, "Hello");
|
||||
// The height becomes the default for the next text.
|
||||
s.cmdline("mtext 0,0 h").unwrap();
|
||||
assert!(s.prompt_text().contains("0.5000"), "{}", s.prompt_text());
|
||||
// A picked height is the distance from the first corner.
|
||||
s.cmdline("0,2").unwrap();
|
||||
s.cmdline("j br 30,-10").unwrap();
|
||||
s.cmdline("x").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert_eq!(t.height, 2.0);
|
||||
assert_eq!(t.attach, 9);
|
||||
assert!(at(&t).near(Vec2::new(30.0, -10.0), 1e-9));
|
||||
// Bad answers are reported and asked again.
|
||||
s.cmdline("mtext 0,0 j zz").unwrap();
|
||||
assert!(logged(&s, "Invalid option keyword."));
|
||||
s.cmdline("tl h -1").unwrap();
|
||||
assert!(logged(&s, "Requires a positive height."));
|
||||
s.cmdline("*cancel*").ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_spacing_type_and_factor_or_distance() {
|
||||
let mut s = Session::new();
|
||||
s.script("MTEXT 0,10 l e 1.5x 20,0\nA\nB\n\n").unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert!(t.line_spacing_exact);
|
||||
assert_eq!(t.line_spacing, 1.5);
|
||||
// A distance: 1x is 5/3 of the height (0.2 here), so 2/3 is 2x.
|
||||
s.script("MTEXT 0,10 l a 0.6667 20,0\nA\n\n").unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert!(!t.line_spacing_exact);
|
||||
assert!((t.line_spacing - 2.0).abs() < 1e-3, "{}", t.line_spacing);
|
||||
// Out of range.
|
||||
s.cmdline("mtext 0,0 l e 9x").unwrap();
|
||||
assert!(logged(&s, "Requires a factor between 0.25x and 4x"));
|
||||
s.cmdline("*cancel*").ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_turns_the_box() {
|
||||
let mut s = Session::new();
|
||||
// Rotated 90 degrees: the box's width runs up from the first corner, its top edge through it.
|
||||
s.script("MTEXT 0,0 r 90 5,20\nUp\n\n").unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert!((t.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
|
||||
assert!((t.width - 20.0).abs() < 1e-9, "{}", t.width);
|
||||
assert!(at(&t).near(Vec2::new(0.0, 0.0), 1e-9), "top left stays at the first corner: {:?}", at(&t));
|
||||
// A picked rotation is the angle from the first corner.
|
||||
s.script("MTEXT 1,1 r 2,2 10,0\nDiag\n\n").unwrap();
|
||||
assert!((last_mtext(&s).rotation - std::f64::consts::FRAC_PI_4).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_replaces_the_opposite_corner() {
|
||||
let mut s = Session::new();
|
||||
s.script("MTEXT 2,3 j tc w 12\nCentred\nTwo lines\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let t = last_mtext(&s);
|
||||
assert_eq!(t.width, 12.0);
|
||||
assert_eq!(t.attach, 2);
|
||||
assert!(at(&t).near(Vec2::new(2.0, 3.0), 1e-9), "the first point is the insertion point");
|
||||
assert_eq!(t.contents, "Centred\\PTwo lines");
|
||||
// Picked: the distance along the text direction.
|
||||
s.script("MTEXT 0,0 w 7,4\nX\n\n").unwrap();
|
||||
assert_eq!(last_mtext(&s).width, 7.0);
|
||||
// Zero: no wrapping.
|
||||
s.script("MTEXT 0,0 w 0\nY\n\n").unwrap();
|
||||
assert_eq!(last_mtext(&s).width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_and_columns() {
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().text_styles.push(TextStyle { name: "Notes".into(), ..TextStyle::default() });
|
||||
s.script("MTEXT 0,10 s notes 20,0\nStyled\n\n").unwrap();
|
||||
assert_eq!(last_mtext(&s).style, "Notes");
|
||||
s.cmdline("mtext 0,0 s nosuch").unwrap();
|
||||
assert!(logged(&s, "Cannot find text style \"nosuch\"."));
|
||||
s.cmdline("?").unwrap();
|
||||
assert!(logged(&s, "Text styles: Standard, Notes"));
|
||||
s.cmdline("").unwrap();
|
||||
// Columns: No is accepted; Dynamic and Static say they're not available, the text stays one column.
|
||||
s.cmdline("c d").unwrap();
|
||||
assert!(logged(&s, "Text columns are not available yet"));
|
||||
s.cmdline("c n 20,-10").unwrap();
|
||||
s.cmdline("One column").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert_eq!(t.contents, "One column");
|
||||
assert_eq!(t.style, "Standard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_takes_justify_style_and_spacing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mtext", &json!({"at": [1, 2], "text": "a\nb", "justify": "BR", "lineSpacing": 1.25, "lineSpacingExact": true})).unwrap();
|
||||
let t = last_mtext(&s);
|
||||
assert_eq!(t.attach, 9);
|
||||
assert!(t.line_spacing_exact && t.line_spacing == 1.25);
|
||||
assert!(s.execute("mtext", &json!({"at": [1, 2], "text": "a", "justify": "XX"})).is_err());
|
||||
assert!(s.execute("mtext", &json!({"at": [1, 2], "text": "a", "style": "missing"})).is_err());
|
||||
}
|
||||
@@ -308,6 +308,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
style: t.s(7).unwrap_or_else(|| "Standard".into()),
|
||||
contents,
|
||||
line_spacing: t.fd(44, 1.0),
|
||||
line_spacing_exact: t.i(73) == Some(2),
|
||||
})
|
||||
}
|
||||
"ATTDEF" => EntityKind::AttDef(Attrib {
|
||||
|
||||
@@ -549,6 +549,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
if t.rotation != 0.0 {
|
||||
w.f(50, t.rotation.to_degrees());
|
||||
}
|
||||
// Line spacing style: 1 = at least, 2 = exactly.
|
||||
w.i(73, if t.line_spacing_exact { 2 } else { 1 });
|
||||
w.f(44, t.line_spacing);
|
||||
}
|
||||
EntityKind::Insert(i) => {
|
||||
@@ -993,6 +995,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
|
||||
style: style.text_style.clone(),
|
||||
contents: g.value.clone(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1058,6 +1061,7 @@ fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
|
||||
style: "Standard".into(),
|
||||
contents: cell.text.clone(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ fn sample() -> Drawing {
|
||||
style: "Standard".into(),
|
||||
contents: "line one\\Pline two".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1127,6 +1128,7 @@ fn intl_sample() -> Drawing {
|
||||
style: "Standard".into(),
|
||||
contents: format!("{INTL}\\P第二行 Ünïcødé"),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1348,6 +1350,7 @@ fn mleader_is_written_as_leader_and_mtext() {
|
||||
contents: "Note".into(),
|
||||
style: "Standard".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
};
|
||||
let m = cadcraft_doc::MLeader {
|
||||
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0)]],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! MTEXT line spacing style (group 73: 1 at least, 2 exactly) round-trips through DXF (#408).
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, MText, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
#[test]
|
||||
fn line_spacing_style_roundtrips() {
|
||||
for exact in [false, true] {
|
||||
let mut d = Drawing::new_metric();
|
||||
let t = MText {
|
||||
insert: Vec3::new(1.0, 2.0, 0.0),
|
||||
height: 2.5,
|
||||
width: 40.0,
|
||||
attach: 5,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "a\\Pb".into(),
|
||||
line_spacing: 1.5,
|
||||
line_spacing_exact: exact,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::MText(t.clone())).unwrap();
|
||||
let bytes = cadcraft_io::write(&d, "a.dxf").unwrap();
|
||||
let back = cadcraft_io::read(&bytes, "a.dxf").unwrap();
|
||||
let Some(EntityKind::MText(r)) = back.model.iter().map(|e| e.kind.clone()).next() else { panic!("no mtext") };
|
||||
assert_eq!(r.line_spacing_exact, exact);
|
||||
assert_eq!(r.line_spacing, 1.5);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,7 @@ fn truetype_mtext_and_dimension_text() {
|
||||
style: "Standard".into(),
|
||||
contents: "A{\\C1;B}\\P\\S1/2;".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user