Multiline attributes: read, draw and save their text (they were lost on save) (#854)
* Multiline attributes: read, draw and save their text A multiline attribute (DXF attribute type 2, or 4 for a definition) has its text as embedded MTEXT after its own groups (`101 Embedded Object`); its own group 1 is empty. CADCraft read only the own groups, so the attribute drew nothing, and a save wrote it back empty: its text was lost. - `Attrib::multiline` keeps the multiline text; `text.value` holds its plain text, which ATTEDIT and the other value editors change; an edited value replaces the contents (`Attrib::multiline_text`). - DXF read: ATTRIB and ATTDEF split at the marker; the MTEXT parsing is shared with MTEXT entities (`mtext_from`). - Drawn as multiline text; ZOOM Extents counts it. - DXF write (2007 and later; earlier versions get one line): attribute type, alignment point and the embedded MTEXT, only what differs from unrotated text, as AutoCAD writes it. - INSERT and ATTSYNC give references the definition's multiline text placed with them; MOVE/ROTATE/SCALE, Properties rotation and scale, annotative scaling and FLATTEN carry it. - Tests: synthetic multiline ATTDEF and ATTRIB (read, drawn, saved, edited, pre-2007 save, damaged embedded groups); engine INSERT, MOVE, ATTEDIT and ATTSYNC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Multiline attributes: read MTEXT direction from its own groups (#667, as #834 does) #834 moves these lines' fix into the MTEXT reader; the same fix in the shared mtext_from keeps the two compatible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
20 files changed
+735
-71
No files matched your search
@@ -194,6 +194,11 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
a.text.insert = o + (a.text.insert - o) * f;
|
||||
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
|
||||
a.text.height *= f;
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
|
||||
ml.mtext.height *= f;
|
||||
ml.mtext.width *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -349,6 +349,68 @@ pub struct Attrib {
|
||||
/// An attribute's own properties; each one left unset follows the attribute definition.
|
||||
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
|
||||
pub props: AttribProps,
|
||||
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
|
||||
/// multiline text. `text.value` holds the plain text, which value edits change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multiline: Option<MultilineAttrib>,
|
||||
}
|
||||
|
||||
/// The multiline text of a multiline attribute.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultilineAttrib {
|
||||
pub mtext: MText,
|
||||
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
|
||||
/// since, and its value replaces the contents.
|
||||
pub read_as: String,
|
||||
}
|
||||
|
||||
impl MultilineAttrib {
|
||||
/// This text placed by `m` (a block reference's transform, for its attribute made from a
|
||||
/// definition).
|
||||
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
|
||||
let mut k = EntityKind::MText(self.mtext.clone());
|
||||
k.transform(m);
|
||||
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
|
||||
MultilineAttrib { mtext, read_as: self.read_as.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Attrib {
|
||||
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
|
||||
pub fn transform_multiline(&mut self, m: &Mat3) {
|
||||
if let Some(ml) = &mut self.multiline {
|
||||
*ml = ml.transformed(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
|
||||
/// replaced by the value when the value was edited since it was read.
|
||||
pub fn multiline_text(&self) -> Option<MText> {
|
||||
let m = self.multiline.as_ref()?;
|
||||
let mut t = m.mtext.clone();
|
||||
if self.text.value != m.read_as {
|
||||
t.contents = mtext_literal(&self.text.value);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
|
||||
/// paragraph breaks.
|
||||
pub fn mtext_literal(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'{' => out.push_str("\\{"),
|
||||
'}' => out.push_str("\\}"),
|
||||
'\n' => out.push_str("\\P"),
|
||||
'\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
|
||||
@@ -1165,7 +1227,10 @@ impl EntityKind {
|
||||
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
|
||||
}
|
||||
EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
|
||||
EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
|
||||
EntityKind::AttDef(a) => {
|
||||
transform_text(&mut a.text, m, s, rot, mirror);
|
||||
a.transform_multiline(m);
|
||||
}
|
||||
EntityKind::MText(t) => {
|
||||
t3(&mut t.insert);
|
||||
t.height *= s;
|
||||
@@ -1187,6 +1252,7 @@ impl EntityKind {
|
||||
}
|
||||
for a in &mut i.attribs {
|
||||
transform_text(&mut a.text, m, s, rot, false);
|
||||
a.transform_multiline(m);
|
||||
}
|
||||
i.col_spacing *= s;
|
||||
i.row_spacing *= s;
|
||||
|
||||
@@ -132,7 +132,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible {
|
||||
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
@@ -314,7 +317,15 @@ mod tests {
|
||||
#[test]
|
||||
fn right_justified_attdef_is_left_of_alignment_point() {
|
||||
let text = text(HAlign::Right, VAlign::Baseline);
|
||||
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let a = crate::Attrib {
|
||||
tag: "HELLO".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
};
|
||||
let b = bounds(EntityKind::AttDef(a));
|
||||
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ fn visible_attributes_of_a_missing_block_still_count() {
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
};
|
||||
let ins = |block: &str, attribs: Vec<Attrib>| {
|
||||
Entity::new(
|
||||
@@ -437,6 +438,7 @@ fn attdef_in_block_is_ignored_in_insert_extents() {
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
}),
|
||||
);
|
||||
let d = drawing_with_block(attdef);
|
||||
|
||||
@@ -204,6 +204,7 @@ pub(crate) fn insert_with(
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -276,6 +277,7 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
|
||||
constant: false,
|
||||
prompt: prompt.into(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,15 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
|
||||
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
|
||||
.map(|a| a.text.value.clone())
|
||||
.unwrap_or_else(|| ad.text.value.clone());
|
||||
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
|
||||
Attrib {
|
||||
tag: ad.tag.clone(),
|
||||
text: t,
|
||||
invisible: ad.invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1315,6 +1315,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
EntityKind::AttDef(a) => {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => z(&mut t.insert),
|
||||
EntityKind::Insert(i) => {
|
||||
@@ -1322,6 +1325,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
for a in &mut i.attribs {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
|
||||
@@ -341,6 +341,12 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q.y = r.y;
|
||||
}
|
||||
t.rotation = cadcraft_geom::norm_angle(t.rotation + turn);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
let r = ml.mtext.insert.xy().rotate_about(base, turn);
|
||||
ml.mtext.insert.x = r.x;
|
||||
ml.mtext.insert.y = r.y;
|
||||
ml.mtext.rotation = cadcraft_geom::norm_angle(ml.mtext.rotation + turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(x) = num("scale").filter(|x| *x != 0.0) {
|
||||
@@ -353,6 +359,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let EntityKind::Text(t) = k {
|
||||
a.text = t;
|
||||
}
|
||||
a.transform_multiline(&m);
|
||||
}
|
||||
}
|
||||
i.scale = Vec3::new(x, x, x);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! A block with a multiline attribute definition: its references get the multiline text placed
|
||||
//! with them, it moves with them, and an attribute edit replaces it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Attrib, Block, Common, Entity, EntityKind, Handle, MText, MultilineAttrib, Text};
|
||||
use cadcraft_engine::geom::Vec3;
|
||||
use serde_json::json;
|
||||
|
||||
/// A session whose block TAG has a multiline definition LABEL ("SITE" / "NAME") at (10, 3).
|
||||
fn session() -> Session {
|
||||
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
|
||||
let text = Text {
|
||||
insert: Vec3::new(2.0, 2.0, 0.0),
|
||||
align_pt: None,
|
||||
height: 2.0,
|
||||
value: "SITE\nNAME".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
};
|
||||
let mtext = MText {
|
||||
insert: Vec3::new(10.0, 3.0, 0.0),
|
||||
height: 2.0,
|
||||
width: 0.0,
|
||||
attach: 8,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "SITE\\PNAME".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
let def = Attrib {
|
||||
tag: "LABEL".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: "Site".into(),
|
||||
props: Default::default(),
|
||||
multiline: Some(MultilineAttrib { mtext, read_as: "SITE\nNAME".into() }),
|
||||
};
|
||||
let mut b = Block::new("TAG");
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity { handle: h, common: Common::default(), kind: EntityKind::AttDef(def) });
|
||||
d.blocks.insert("TAG".into(), Arc::new(b));
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(d, "tags", None);
|
||||
s
|
||||
}
|
||||
|
||||
fn label(s: &Session, h: Handle) -> Attrib {
|
||||
match s.doc().unwrap().entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Insert(i)) => i.attribs[0].clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_get_the_multiline_text_and_carry_it() {
|
||||
let mut s = session();
|
||||
let r = s.execute("insert", &json!({ "name": "TAG", "at": [10, 5] })).unwrap();
|
||||
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
let a = label(&s, h);
|
||||
assert_eq!(a.text.value, "SITE\nNAME");
|
||||
let m = a.multiline_text().expect("multiline");
|
||||
assert_eq!((m.insert.x, m.insert.y, m.contents.as_str()), (20.0, 8.0, "SITE\\PNAME"));
|
||||
|
||||
// MOVE carries it.
|
||||
s.execute("move", &json!({ "handles": [h.hex()], "delta": [5, 0] })).unwrap();
|
||||
let m = label(&s, h).multiline_text().unwrap();
|
||||
assert_eq!((m.insert.x, m.insert.y), (25.0, 8.0));
|
||||
|
||||
// An edited value replaces the multiline text; ATTSYNC keeps the value and the text.
|
||||
s.execute("attedit", &json!({ "handle": h.hex(), "values": { "label": "WELL 7" } })).unwrap();
|
||||
assert_eq!(label(&s, h).multiline_text().map(|m| m.contents), Some("WELL 7".to_string()));
|
||||
s.execute("attsync", &json!({ "name": "TAG" })).unwrap();
|
||||
let a = label(&s, h);
|
||||
assert_eq!(a.text.value, "WELL 7");
|
||||
assert_eq!(a.multiline_text().map(|m| (m.contents, m.insert.x)), Some(("WELL 7".to_string(), 25.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_given_on_insert_replaces_the_definition_text() {
|
||||
let mut s = session();
|
||||
let r = s.execute("insert", &json!({ "name": "TAG", "at": [0, 0], "attribs": { "LABEL": "NORTH" } })).unwrap();
|
||||
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(label(&s, h).multiline_text().map(|m| m.contents), Some("NORTH".to_string()));
|
||||
}
|
||||
@@ -437,7 +437,15 @@ fn attribs(d: &Drawing, ins: &Insert, values: &[(String, String)]) -> Vec<Attrib
|
||||
k.transform(&m);
|
||||
let EntityKind::Text(mut text) = k else { continue };
|
||||
text.value = value.clone();
|
||||
out.push(Attrib { tag: ad.tag.clone(), text, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() });
|
||||
out.push(Attrib {
|
||||
tag: ad.tag.clone(),
|
||||
text,
|
||||
invisible: ad.invisible,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
+92
-52
@@ -105,6 +105,69 @@ fn attrib_props(t: &T, insert_layer: &str) -> AttribProps {
|
||||
}
|
||||
}
|
||||
|
||||
/// An ATTRIB's or ATTDEF's groups split at its embedded multiline text (`101 Embedded Object`):
|
||||
/// the attribute's own groups and, for a multiline attribute, the MTEXT groups after the marker.
|
||||
fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) {
|
||||
match tags.iter().position(|t| t.code == 101) {
|
||||
Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)),
|
||||
None => (tags, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline attribute's text, from the MTEXT groups embedded in it.
|
||||
fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option<MultilineAttrib> {
|
||||
let mtext = mtext_from(embedded.filter(|e| !e.is_empty())?, c);
|
||||
let read_as = cadcraft_fonts::plain_mtext(&mtext.contents);
|
||||
Some(MultilineAttrib { mtext, read_as })
|
||||
}
|
||||
|
||||
/// Multiline text from MTEXT groups (an MTEXT entity's, or a multiline attribute's embedded ones).
|
||||
fn mtext_from(tags: &[Tag], c: &Common) -> MText {
|
||||
let t = T(tags);
|
||||
// Contents: code 3 chunks followed by code 1.
|
||||
let mut contents = String::new();
|
||||
for tg in tags.iter().filter(|x| x.code == 3) {
|
||||
contents.push_str(&tg.str());
|
||||
}
|
||||
contents.push_str(&t.s(1).unwrap_or_default());
|
||||
let contents = mtext_dxf::decode_contents(&contents);
|
||||
let columns = mtext_dxf::read_columns(tags);
|
||||
// The direction comes from the MTEXT's own groups only: in an R2018+ embedded object
|
||||
// (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834).
|
||||
let own = T(mtext_dxf::before_columns(tags));
|
||||
let mut rotation = match (own.f(11), own.f(21)) {
|
||||
(Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(),
|
||||
_ => own.fd(50, 0.0).to_radians(),
|
||||
};
|
||||
let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8;
|
||||
if c.extrusion.z < 0.0 {
|
||||
// Insertion point and direction are WCS, but the line height runs along extrusion ×
|
||||
// direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with
|
||||
// the same footprint, the text turns round and left/right attachment swap.
|
||||
rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI);
|
||||
attach = match attach {
|
||||
1 | 4 | 7 => attach + 2,
|
||||
3 | 6 | 9 => attach - 2,
|
||||
a => a,
|
||||
};
|
||||
}
|
||||
MText {
|
||||
insert: t.p(10),
|
||||
height: t.fd(40, 0.2).max(1e-9),
|
||||
width: match &columns {
|
||||
Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w,
|
||||
_ => t.fd(41, 0.0).max(0.0),
|
||||
},
|
||||
attach,
|
||||
rotation,
|
||||
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),
|
||||
columns: columns.map(|(c, _)| c),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_from(t: &T) -> Text {
|
||||
let h = match t.i(72).unwrap_or(0) {
|
||||
1 => HAlign::Center,
|
||||
@@ -293,58 +356,26 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
"RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }),
|
||||
"XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }),
|
||||
"TEXT" => EntityKind::Text(text_from(&t)),
|
||||
"MTEXT" => {
|
||||
// Contents: code 3 chunks followed by code 1.
|
||||
let mut contents = String::new();
|
||||
for tg in tags.iter().filter(|x| x.code == 3) {
|
||||
contents.push_str(&tg.str());
|
||||
"MTEXT" => EntityKind::MText(mtext_from(tags, &c)),
|
||||
"ATTDEF" => {
|
||||
// A multiline definition has its text as embedded MTEXT after its own groups.
|
||||
let (own, embedded) = split_embedded(tags);
|
||||
let multiline = multiline_attrib(embedded, &c);
|
||||
let own = T(own);
|
||||
let mut text = text_from(&own);
|
||||
if let Some(m) = &multiline {
|
||||
text.value.clone_from(&m.read_as);
|
||||
}
|
||||
contents.push_str(&t.s(1).unwrap_or_default());
|
||||
let contents = mtext_dxf::decode_contents(&contents);
|
||||
let columns = mtext_dxf::read_columns(tags);
|
||||
// The direction comes from the MTEXT's own groups only: in an R2018+ embedded object
|
||||
// (group 101), 11 is the insertion point, not the X-axis direction (#667).
|
||||
let own = T(mtext_dxf::before_columns(tags));
|
||||
let mut rotation = match (own.f(11), own.f(21)) {
|
||||
(Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(),
|
||||
_ => own.fd(50, 0.0).to_radians(),
|
||||
};
|
||||
let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8;
|
||||
if c.extrusion.z < 0.0 {
|
||||
// Insertion point and direction are WCS, but the line height runs along extrusion ×
|
||||
// direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with
|
||||
// the same footprint, the text turns round and left/right attachment swap.
|
||||
rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI);
|
||||
attach = match attach {
|
||||
1 | 4 | 7 => attach + 2,
|
||||
3 | 6 | 9 => attach - 2,
|
||||
a => a,
|
||||
};
|
||||
}
|
||||
EntityKind::MText(MText {
|
||||
insert: t.p(10),
|
||||
height: t.fd(40, 0.2).max(1e-9),
|
||||
width: match &columns {
|
||||
Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w,
|
||||
_ => t.fd(41, 0.0).max(0.0),
|
||||
},
|
||||
attach,
|
||||
rotation,
|
||||
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),
|
||||
columns: columns.map(|(c, _)| c),
|
||||
EntityKind::AttDef(Attrib {
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: own.i(70).unwrap_or(0) & 2 != 0,
|
||||
prompt: own.s(3).unwrap_or_default(),
|
||||
props: Default::default(),
|
||||
multiline,
|
||||
})
|
||||
}
|
||||
"ATTDEF" => EntityKind::AttDef(Attrib {
|
||||
tag: t.s(2).unwrap_or_default(),
|
||||
text: text_from(&t),
|
||||
invisible: t.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: t.i(70).unwrap_or(0) & 2 != 0,
|
||||
prompt: t.s(3).unwrap_or_default(),
|
||||
props: Default::default(),
|
||||
}),
|
||||
"INSERT" => {
|
||||
// A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale,
|
||||
// rotation and column spacing are in that OCS, where x runs the other way. In the WCS that
|
||||
@@ -1135,13 +1166,22 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx, ski
|
||||
while let Some((k2, t2)) = recs.get(i) {
|
||||
if k2 == "ATTRIB" {
|
||||
let tt = T(t2);
|
||||
// A multiline attribute has its text as embedded MTEXT after its own groups.
|
||||
let (own, embedded) = split_embedded(t2);
|
||||
let multiline = multiline_attrib(embedded, &common(&tt));
|
||||
let own = T(own);
|
||||
let mut text = text_from(&own);
|
||||
if let Some(m) = &multiline {
|
||||
text.value.clone_from(&m.read_as);
|
||||
}
|
||||
attribs.push(Attrib {
|
||||
tag: tt.s(2).unwrap_or_default(),
|
||||
text: text_from(&tt),
|
||||
invisible: tt.i(70).unwrap_or(0) & 1 != 0,
|
||||
tag: own.s(2).unwrap_or_default(),
|
||||
text,
|
||||
invisible: own.i(70).unwrap_or(0) & 1 != 0,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: attrib_props(&tt, &ins_layer),
|
||||
multiline,
|
||||
});
|
||||
i += 1;
|
||||
} else {
|
||||
|
||||
@@ -418,6 +418,38 @@ fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, a: &Attrib) -> (Common,
|
||||
(c, base)
|
||||
}
|
||||
|
||||
/// A multiline attribute's groups after its own (DXF 2007 and later): attribute type 2 (ATTRIB)
|
||||
/// or 4 (ATTDEF), its alignment point, then its text as embedded MTEXT (`101 Embedded Object`).
|
||||
/// Only what differs from unrotated text goes in the embedded object, as AutoCAD writes it.
|
||||
fn multiline_tags(w: &mut W, a: &Attrib, attdef: bool) {
|
||||
let Some(m) = a.multiline_text() else { return };
|
||||
w.i(71, if attdef { 4 } else { 2 });
|
||||
w.i(72, if attdef { 0 } else { 1 });
|
||||
w.p(11, a.text.align_pt.unwrap_or(a.text.insert));
|
||||
w.s(101, "Embedded Object");
|
||||
w.p(10, m.insert);
|
||||
w.f(40, m.height);
|
||||
w.f(41, m.width);
|
||||
w.f(46, 0.0);
|
||||
w.i(71, i64::from(m.attach));
|
||||
w.i(72, 5);
|
||||
let chars: Vec<char> = crate::dxf_read::mtext_dxf::encode_contents(&m.contents).chars().collect();
|
||||
let chunks: Vec<String> = chars.chunks(250).map(|c| c.iter().collect()).collect();
|
||||
let n = chunks.len();
|
||||
for (i, c) in chunks.iter().enumerate() {
|
||||
w.s(if i + 1 == n { 1 } else { 3 }, c.clone());
|
||||
}
|
||||
if n == 0 {
|
||||
w.s(1, "");
|
||||
}
|
||||
w.s(7, &m.style);
|
||||
if m.rotation != 0.0 {
|
||||
w.p(11, Vec3::new(m.rotation.cos(), m.rotation.sin(), 0.0));
|
||||
}
|
||||
w.i(73, if m.line_spacing_exact { 2 } else { 1 });
|
||||
w.f(44, m.line_spacing);
|
||||
}
|
||||
|
||||
/// ATTRIB/ATTDEF flags (group 70): 1 invisible, 2 constant.
|
||||
fn attrib_flags(a: &Attrib) -> i64 {
|
||||
i64::from(a.invisible) | if a.constant { 2 } else { 0 }
|
||||
@@ -672,6 +704,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
w.s(0, "ATTDEF");
|
||||
common(w, e, owner, paper, "AcDbText");
|
||||
text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), Some(&a.prompt));
|
||||
if cx.target >= Version::R2007 {
|
||||
multiline_tags(w, a, true);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => {
|
||||
w.s(0, "MTEXT");
|
||||
@@ -733,6 +768,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
}
|
||||
w.s(100, "AcDbText");
|
||||
text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), None);
|
||||
if cx.target >= Version::R2007 {
|
||||
multiline_tags(w, a, false);
|
||||
}
|
||||
}
|
||||
let sh = w.h();
|
||||
w.s(0, "SEQEND");
|
||||
|
||||
+19
-5
@@ -273,6 +273,7 @@ fn block_definition_properties_and_constant_attdefs_roundtrip() {
|
||||
constant,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
};
|
||||
for (i, a) in [attdef("COMPANY", false, true), attdef("SECRET", true, true), attdef("SHEET", false, false)].into_iter().enumerate() {
|
||||
b.entities.push(Entity::new(Handle(0x500 + i as u64), EntityKind::AttDef(a)));
|
||||
@@ -1304,15 +1305,27 @@ fn intl_sample() -> Drawing {
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let def =
|
||||
Attrib {
|
||||
tag: "图号".into(), text: intl_text("默认"), invisible: false, constant: false, prompt: String::new(), props: Default::default()
|
||||
let def = Attrib {
|
||||
tag: "图号".into(),
|
||||
text: intl_text("默认"),
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
};
|
||||
let mut b = Block::new(&block);
|
||||
b.entities.push(Entity::new(Handle(0x50), EntityKind::AttDef(def)));
|
||||
d.blocks.insert(block.clone(), std::sync::Arc::new(b));
|
||||
let att =
|
||||
Attrib { tag: "图号".into(), text: intl_text(INTL), invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let att = Attrib {
|
||||
tag: "图号".into(),
|
||||
text: intl_text(INTL),
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
};
|
||||
d.add(
|
||||
&Space::Model,
|
||||
on,
|
||||
@@ -1571,6 +1584,7 @@ fn attdef_prompt_survives_dxf_roundtrip() {
|
||||
constant: false,
|
||||
prompt: prompt.into(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
})
|
||||
};
|
||||
d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap();
|
||||
|
||||
@@ -24,7 +24,7 @@ fn text(x: f64, value: &str) -> Text {
|
||||
}
|
||||
|
||||
fn attrib(tag: &str, x: f64, props: AttribProps) -> Attrib {
|
||||
Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props }
|
||||
Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props, multiline: None }
|
||||
}
|
||||
|
||||
/// Block TAGS with definitions OWN (layer 0), DEFLAYER (layer TXT) and RED (layer 0, red),
|
||||
|
||||
@@ -98,6 +98,7 @@ fn spline_leader_with_block_content_and_attributes() {
|
||||
constant: false,
|
||||
prompt: "Number".into(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
}),
|
||||
));
|
||||
d.blocks.insert(blk.name.clone(), std::sync::Arc::new(blk));
|
||||
@@ -110,7 +111,15 @@ fn spline_leader_with_block_content_and_attributes() {
|
||||
k.transform(&xf);
|
||||
let EntityKind::Text(mut placed) = k else { unreachable!() };
|
||||
placed.value = "42".into();
|
||||
ins.attribs.push(Attrib { tag: "NUM".into(), text: placed, invisible: false, constant: false, prompt: String::new(), props: Default::default() });
|
||||
ins.attribs.push(Attrib {
|
||||
tag: "NUM".into(),
|
||||
text: placed,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
});
|
||||
let m = MLeader {
|
||||
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(4.0, 5.0, 0.0), Vec3::new(8.0, 4.0, 0.0)]],
|
||||
landing: Vec3::new(10.0, 3.0, 0.0),
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Multiline attributes (DXF attribute type 2, or 4 for a definition): their text is embedded
|
||||
//! MTEXT after the attribute's own groups (`101 Embedded Object`). It is read, drawn, written
|
||||
//! back and survives a save; edits to the value replace it.
|
||||
//!
|
||||
//! The fixture is synthetic, laid out as the DXF Reference describes these attributes: block TAG
|
||||
//! with a multiline definition LABEL, and a reference whose LABEL attribute has its own text.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_io::{SaveOptions, Version, read_dxf};
|
||||
|
||||
/// DXF text from lines of `code value` (the value may be empty).
|
||||
fn dxf(src: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for line in src.lines().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
let (code, value) = line.split_once(' ').unwrap_or((line, ""));
|
||||
out.push_str(&format!("{code}\n{value}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const FIXTURE: &str = "
|
||||
0 SECTION
|
||||
2 HEADER
|
||||
9 $ACADVER
|
||||
1 AC1032
|
||||
9 $HANDSEED
|
||||
5 200
|
||||
0 ENDSEC
|
||||
0 SECTION
|
||||
2 TABLES
|
||||
0 TABLE
|
||||
2 BLOCK_RECORD
|
||||
5 1
|
||||
330 0
|
||||
100 AcDbSymbolTable
|
||||
70 3
|
||||
0 BLOCK_RECORD
|
||||
5 1F
|
||||
330 1
|
||||
100 AcDbSymbolTableRecord
|
||||
100 AcDbBlockTableRecord
|
||||
2 *Model_Space
|
||||
340 0
|
||||
0 BLOCK_RECORD
|
||||
5 1E
|
||||
330 1
|
||||
100 AcDbSymbolTableRecord
|
||||
100 AcDbBlockTableRecord
|
||||
2 *Paper_Space
|
||||
340 0
|
||||
0 BLOCK_RECORD
|
||||
5 1D
|
||||
330 1
|
||||
100 AcDbSymbolTableRecord
|
||||
100 AcDbBlockTableRecord
|
||||
2 TAG
|
||||
340 0
|
||||
0 ENDTAB
|
||||
0 ENDSEC
|
||||
0 SECTION
|
||||
2 BLOCKS
|
||||
0 BLOCK
|
||||
5 20
|
||||
330 1F
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbBlockBegin
|
||||
2 *Model_Space
|
||||
70 0
|
||||
10 0
|
||||
20 0
|
||||
30 0
|
||||
3 *Model_Space
|
||||
1
|
||||
0 ENDBLK
|
||||
5 21
|
||||
330 1F
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbBlockEnd
|
||||
0 BLOCK
|
||||
5 24
|
||||
330 1D
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbBlockBegin
|
||||
2 TAG
|
||||
70 2
|
||||
10 0
|
||||
20 0
|
||||
30 0
|
||||
3 TAG
|
||||
1
|
||||
0 LINE
|
||||
5 30
|
||||
330 1D
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbLine
|
||||
10 0
|
||||
20 0
|
||||
30 0
|
||||
11 20
|
||||
21 0
|
||||
31 0
|
||||
0 ATTDEF
|
||||
5 31
|
||||
330 1D
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbText
|
||||
10 2
|
||||
20 2
|
||||
30 0
|
||||
40 2
|
||||
1 SITE NAME
|
||||
7 Standard
|
||||
72 1
|
||||
11 10
|
||||
21 3
|
||||
31 0
|
||||
100 AcDbAttributeDefinition
|
||||
280 0
|
||||
3 Site
|
||||
2 LABEL
|
||||
70 0
|
||||
73 0
|
||||
74 1
|
||||
280 1
|
||||
71 4
|
||||
72 0
|
||||
11 10
|
||||
21 3
|
||||
31 0
|
||||
101 Embedded Object
|
||||
10 10
|
||||
20 3
|
||||
30 0
|
||||
40 2
|
||||
41 0
|
||||
46 0
|
||||
71 8
|
||||
72 5
|
||||
1 SITE\\PNAME
|
||||
7 Standard
|
||||
73 1
|
||||
44 1
|
||||
0 ENDBLK
|
||||
5 25
|
||||
330 1D
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbBlockEnd
|
||||
0 ENDSEC
|
||||
0 SECTION
|
||||
2 ENTITIES
|
||||
0 INSERT
|
||||
5 40
|
||||
330 1F
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbBlockReference
|
||||
66 1
|
||||
2 TAG
|
||||
10 100
|
||||
20 50
|
||||
30 0
|
||||
0 ATTRIB
|
||||
5 41
|
||||
330 40
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
100 AcDbText
|
||||
10 102
|
||||
20 52
|
||||
30 0
|
||||
40 2
|
||||
1
|
||||
7 Standard
|
||||
72 1
|
||||
11 110
|
||||
21 53
|
||||
31 0
|
||||
100 AcDbAttribute
|
||||
280 0
|
||||
2 LABEL
|
||||
70 0
|
||||
74 1
|
||||
280 1
|
||||
71 2
|
||||
72 1
|
||||
11 110
|
||||
21 53
|
||||
31 0
|
||||
101 Embedded Object
|
||||
10 110
|
||||
20 53
|
||||
30 0
|
||||
40 2
|
||||
41 0
|
||||
46 0
|
||||
71 8
|
||||
72 5
|
||||
1 NORTH\\PWELL 7
|
||||
7 Standard
|
||||
73 1
|
||||
44 1
|
||||
0 SEQEND
|
||||
5 42
|
||||
330 40
|
||||
100 AcDbEntity
|
||||
8 0
|
||||
0 ENDSEC
|
||||
0 SECTION
|
||||
2 OBJECTS
|
||||
0 DICTIONARY
|
||||
5 C
|
||||
330 0
|
||||
100 AcDbDictionary
|
||||
281 1
|
||||
0 ENDSEC
|
||||
0 EOF
|
||||
";
|
||||
|
||||
/// The drawing saved as the app saves it (`write`: its own version, AutoCAD 2018 here).
|
||||
fn save(d: &Drawing) -> String {
|
||||
String::from_utf8(cadcraft_io::write(d, "x.dxf").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn reference(d: &Drawing) -> &cadcraft_doc::Insert {
|
||||
match d.model.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i) } else { None }) {
|
||||
Some(i) => i,
|
||||
None => panic!("no reference"),
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(d: &Drawing) -> &cadcraft_doc::Attrib {
|
||||
match d.block("TAG").and_then(|b| b.entities.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a) } else { None })) {
|
||||
Some(a) => a,
|
||||
None => panic!("no definition"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_attributes_are_read_with_their_text() {
|
||||
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
|
||||
let a = &reference(&d).attribs[0];
|
||||
assert_eq!(a.tag, "LABEL");
|
||||
assert_eq!(a.text.value, "NORTH\nWELL 7", "the value is the multiline text's plain text");
|
||||
let m = a.multiline.as_ref().expect("multiline");
|
||||
assert_eq!(m.mtext.contents, "NORTH\\PWELL 7");
|
||||
assert_eq!((m.mtext.insert.x, m.mtext.insert.y, m.mtext.attach), (110.0, 53.0, 8));
|
||||
// The attribute's own groups, not the embedded text's, give the single-line fields.
|
||||
assert_eq!(a.text.insert.x, 102.0);
|
||||
|
||||
let ad = definition(&d);
|
||||
assert_eq!((ad.tag.as_str(), ad.prompt.as_str()), ("LABEL", "Site"));
|
||||
assert_eq!(ad.text.value, "SITE\nNAME");
|
||||
assert_eq!(ad.multiline.as_ref().map(|m| m.mtext.contents.as_str()), Some("SITE\\PNAME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multiline_attribute_is_drawn() {
|
||||
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
|
||||
let opts = cadcraft_render::Options::default();
|
||||
let with = cadcraft_render::build(&d, &cadcraft_doc::Space::Model, &opts);
|
||||
let mut bare = d.clone();
|
||||
let first = bare.model.iter().next().map(|e| e.handle);
|
||||
if let Some(e) = first {
|
||||
bare.model.modify(e, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.attribs.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
let without = cadcraft_render::build(&bare, &cadcraft_doc::Space::Model, &opts);
|
||||
assert!(
|
||||
with.prims.len() > without.prims.len() + 4,
|
||||
"the attribute's two lines of text are drawn ({} vs {})",
|
||||
with.prims.len(),
|
||||
without.prims.len()
|
||||
);
|
||||
// Its text sits where the embedded text is: around (110, 53), bottom-centre attached.
|
||||
assert!(with.bounds.max.y > 53.5 && with.bounds.max.x > 105.0, "{:?}", with.bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_attributes_survive_a_save() {
|
||||
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
|
||||
let out = save(&d);
|
||||
assert_eq!(out.matches("Embedded Object").count(), 2, "the definition's and the attribute's");
|
||||
let again = read_dxf(out.as_bytes()).unwrap();
|
||||
assert_eq!(reference(&again).attribs[0], reference(&d).attribs[0]);
|
||||
assert_eq!(definition(&again), definition(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edited_value_replaces_the_multiline_text() {
|
||||
let mut d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
|
||||
let h = d.model.iter().next().map(|e| e.handle).unwrap();
|
||||
d.model.modify(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
i.attribs[0].text.value = "SOUTH {A}\nWELL 8".into();
|
||||
}
|
||||
});
|
||||
let a = &reference(&d).attribs[0];
|
||||
assert_eq!(a.multiline_text().map(|m| m.contents), Some("SOUTH \\{A\\}\\PWELL 8".to_string()));
|
||||
let again = read_dxf(save(&d).as_bytes()).unwrap();
|
||||
assert_eq!(reference(&again).attribs[0].text.value, "SOUTH {A}\nWELL 8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_before_2007_get_single_line_attributes() {
|
||||
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
|
||||
let out =
|
||||
String::from_utf8(cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(Version::R2004), binary: false }).unwrap()).unwrap();
|
||||
assert!(!out.contains("Embedded Object"));
|
||||
let again = read_dxf(out.as_bytes()).unwrap();
|
||||
let a = &reference(&again).attribs[0];
|
||||
assert!(a.multiline.is_none());
|
||||
// Single-line text can't hold a line break: the text stays, on one line.
|
||||
assert_eq!(a.text.value, "NORTH WELL 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_embedded_text_does_not_panic() {
|
||||
for (from, to) in [
|
||||
("101 Embedded Object\n10 110", "101 Embedded Object"),
|
||||
("71 8\n72 5\n1 NORTH", "71 99\n72 5\n1 NORTH"),
|
||||
("40 2\n41 0\n46 0\n71 8", "40 nan\n41 -5\n46 x\n71 8"),
|
||||
] {
|
||||
let src = FIXTURE.replacen(from, to, 1);
|
||||
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
|
||||
let _ = cadcraft_render::build(&d, &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
|
||||
let _ = read_dxf(save(&d).as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1401,7 +1401,9 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
|
||||
if attmode == 0 || (a.invisible && attmode != 2) {
|
||||
continue;
|
||||
}
|
||||
let (sh, _) = place_text_entity(ctx.d, &a.text, &a.text.value);
|
||||
// A multiline attribute is drawn as its multiline text.
|
||||
let multiline = a.multiline_text();
|
||||
let sh = if multiline.is_none() { Some(place_text_entity(ctx.d, &a.text, &a.text.value).0) } else { None };
|
||||
// Properties of the attribute's definition (as AutoCAD copies them to the attribute
|
||||
// on insertion), except those set on the attribute itself: ByBlock = the reference's,
|
||||
// layer 0 = the reference's layer.
|
||||
@@ -1415,7 +1417,12 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
|
||||
}
|
||||
};
|
||||
if shown {
|
||||
b.shaped(ctx, ink, if b.opts.lineweights { lw } else { 0.0 }, &sh);
|
||||
let lw = if b.opts.lineweights { lw } else { 0.0 };
|
||||
match (&multiline, &sh) {
|
||||
(Some(m), _) => mtext(b, ctx, m, ink, lw),
|
||||
(None, Some(sh)) => b.shaped(ctx, ink, lw, sh),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,15 @@ fn text(x: f64, value: &str) -> Text {
|
||||
}
|
||||
|
||||
fn attdef(handle: u64, tag: &str, x: f64, common: Common) -> Entity {
|
||||
let a = Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let a = Attrib {
|
||||
tag: tag.into(),
|
||||
text: text(x, tag),
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
};
|
||||
Entity { common, ..Entity::new(Handle(handle), EntityKind::AttDef(a)) }
|
||||
}
|
||||
|
||||
@@ -146,6 +154,7 @@ fn attributes_draw_with_their_own_properties() {
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
})
|
||||
.to_vec();
|
||||
let ins = Insert {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dynamic block data read into a model) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and written) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
|
||||
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
|
||||
@@ -72,7 +72,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
|
||||
|---|---|---|---|
|
||||
| LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests |
|
||||
| POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy |
|
||||
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes | yes | ATTDEF prompt kept (#137) |
|
||||
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written |
|
||||
| LEADER | yes | yes | |
|
||||
| MULTILEADER | yes (#459) | yes, native MULTILEADER with MLEADERSTYLE (#459) | several landings per multileader are folded into one; per-object overrides and annotative scale contexts not kept |
|
||||
@@ -145,6 +145,7 @@ our output in AutoCAD black-box.
|
||||
| 2026-10-12 | minor | Dynamic block references: visibility states can be changed and are saved as AutoCAD saves them |
|
||||
| 2026-10-12 | minor | Dynamic block references: definition, values and custom properties read (`blocks.reference`, Properties ▸ Custom) |
|
||||
| 2026-10-12 | minor | Dynamic block data row: parameters and actions read into a model (`blocks.dynamic`); kept by DXF and DWG save |
|
||||
| 2026-10-12 | minor | Multiline attributes (embedded MTEXT) read, drawn and written back instead of losing their text |
|
||||
| 2026-10-11 | minor | Rows refreshed for merged work: code pages and `\M+` (#505), MULTILEADER (#459), IMAGE, MLINE/HELIX/REGION/TOLERANCE, VPORT/VIEW/UCS tables, modelled and raw objects, foreign data (#528), DWT templates and user `.lin`/`.pat`/`.pgp` files (#552), PDF output (#567, #571), plotter devices (#564), data extraction (#433, #534); weighted percentages not re-measured |
|
||||
| 2026-10-11 | minor | DXF (ASCII and binary) and DWG saved at 2000–2018, 2018 by default (#421) |
|
||||
| 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Where CADCraft falls short of AutoCAD
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (F13: HATCHEDIT refuses a bad scale or angle before editing (#724); previously minor: SCRIPT command reading `.scr` files landed; E2 removed) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and saved: F17 narrowed) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
Every known shortfall, one row each, ranked within each section by user impact. This is the work
|
||||
list: agents pick from here (top of a section first), and remove or update a row in the same PR
|
||||
@@ -40,7 +40,7 @@ do them first.
|
||||
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3). The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, and plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
|
||||
| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 |
|
||||
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
|
||||
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset, lock-position or multiple-lines modes, so BATTMAN can't set them; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
||||
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
|
||||
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
|
||||
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 |
|
||||
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT has no Merged Tee and tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
|
||||
@@ -152,6 +152,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (deterministic xorshift generator, no new dependency; known answers at 1e6–1e7 coordinates): S6 removed |
|
||||
| 2026-10-12 | trivial | F13: HATCHEDIT refuses a bad scale or angle before editing (#724) |
|
||||
| 2026-10-12 | minor | F3: dynamic block references show their grips (distance arrows, position squares, visibility/lookup lists, per visibility state) and drag them (`blocks.reference.grips`, `blocks.reference.grip`) |
|
||||
| 2026-10-12 | minor | Multiline attributes read, drawn and saved (they lost their text before): F17 narrowed to creating them |
|
||||
| 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed |
|
||||
| 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes |
|
||||
| 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |
|
||||
|
||||
Reference in new issue
Block a user