diff --git a/crates/dxf/src/model.rs b/crates/dxf/src/model.rs index e705741ef..e3de621ae 100644 --- a/crates/dxf/src/model.rs +++ b/crates/dxf/src/model.rs @@ -27,7 +27,8 @@ pub mod write; pub use document::{Block, Dictionary, Document, EntityRecord, Header, Layout, Record, entity_records, records}; pub use entity::{ - Attrib, Common, Dimension, Entity, EntityKind, Helix, Image, Insert, Leader, LwPolyline, LwVertex, Polyline, Spline, Text, Vertex, Viewport, + Attrib, Common, Dimension, Entity, EntityKind, Helix, Image, Insert, Leader, LwPolyline, LwVertex, MLine, MLineVertex, Polyline, Spline, Text, + Vertex, Viewport, }; pub use hatch::{Gradient, Hatch, HatchEdge, HatchLoop, HatchPath, PatternLine}; pub use tables::{AppId, BlockRecord, DashElement, DimStyle, Layer, Linetype, TextStyle, Ucs, View, Vport, dim_var_code, dim_var_name}; diff --git a/crates/dxf/src/model/entity.rs b/crates/dxf/src/model/entity.rs index ae5cf23ab..634b1a097 100644 --- a/crates/dxf/src/model/entity.rs +++ b/crates/dxf/src/model/entity.rs @@ -6,6 +6,8 @@ //! 3DFACE, MTEXT, LEADER and DIMENSION definition points are world coordinates. Angles are in //! degrees, except an ELLIPSE's parameters, which the format stores in radians. +use std::borrow::Cow; + use super::document::{EntityRecord, Record}; use super::hatch::Hatch; use super::text::{MText, decode_line, split_embedded}; @@ -39,21 +41,40 @@ pub struct Common { pub plot_style: Option, } +/// The AcDbEntity group codes a writer can put after the entity's own subclass marker instead +/// (DXF Reference, Common Group Codes for Entities): paper space, layout, layer, linetype, +/// material, colour, lineweight, linetype scale, visibility, true colour, colour name, +/// transparency, plot style and shadow mode. +const ENTITY_CODES: [i32; 14] = [67, 410, 8, 6, 347, 62, 370, 48, 60, 420, 430, 440, 390, 284]; + /// The groups of the AcDbEntity subclass: the record up to the entity's own subclass marker (all /// of it in files without markers, and in records with no AcDbEntity marker, whose layer and /// colour may follow the entity's marker), or up to an embedded object (101). Later groups reuse /// the codes: an MTEXT's column width is 48. Markers inside an application-defined group do not /// count. -fn entity_groups(tags: &[Tag]) -> &[Tag] { +/// +/// Some writers leave the AcDbEntity subclass without a layer and put its groups right after the +/// entity's own marker. Every entity has a layer, so its absence marks that layout, and the run +/// of [`ENTITY_CODES`] that starts after the marker is read too. The run ends at the first other +/// code, so the entity's own groups that reuse a code are not taken for it. Only such a record +/// pays for the copy. +fn entity_groups(tags: &[Tag]) -> Cow<'_, [Tag]> { let is_entity = |t: &Tag| t.str_ref().is_some_and(|s| s == "AcDbEntity"); let marked = outside_app(tags).any(|(_, t)| t.code == 100 && is_entity(t)); let end = outside_app(tags).find(|(_, t)| (marked && t.code == 100 && !is_entity(t)) || t.code == 101).map_or(tags.len(), |(i, _)| i); - tags.get(..end).unwrap_or(tags) + let groups = tags.get(..end).unwrap_or(tags); + let after_own_marker = marked && tags.get(end).is_some_and(|t| t.code == 100); + if !after_own_marker || outside_app(groups).any(|(_, t)| t.code == 8) { + return Cow::Borrowed(groups); + } + let run = tags.get(end + 1..).unwrap_or_default().iter().take_while(|t| ENTITY_CODES.contains(&t.code)); + Cow::Owned(groups.iter().chain(run).cloned().collect()) } impl Common { pub fn read(tags: &[Tag]) -> Common { - let (all, e) = (Groups(tags), Groups(entity_groups(tags))); + let own = entity_groups(tags); + let (all, e) = (Groups(tags), Groups(&own)); Common { handle: e.handle(5), owner: outside_app(tags).find(|(_, t)| t.code == 330).and_then(|(_, t)| parse_handle(&t.str())), @@ -201,6 +222,42 @@ pub struct Helix { pub constrain: i64, } +/// A vertex of an MLINE. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct MLineVertex { + /// Groups 11/21/31. + pub location: Point, + /// Groups 12/22/32: the direction of the segment that starts here. + pub direction: Point, + /// Groups 13/23/33: the miter direction. + pub miter: Point, + /// Per element of the style: its 74 count of 41 parameters (the distance from the vertex + /// along the miter, then the pairs that start and end its breaks). + pub element_params: Vec>, + /// Per element: its 75 count of 42 area fill parameters. + pub fill_params: Vec>, +} + +/// An MLINE: parallel lines drawn by an MLINESTYLE. +#[derive(Clone, Debug, PartialEq)] +pub struct MLine { + /// Group 2: the style's name. + pub style: String, + /// Group 340: the MLINESTYLE. + pub style_handle: Option, + /// Group 40. + pub scale: f64, + /// Group 70: 0 top, 1 zero (centred), 2 bottom. + pub justification: i64, + /// Group 71: 1 has vertices, 2 closed, 4 start caps suppressed, 8 end caps suppressed. + pub flags: i64, + /// Group 73: the number of elements of the style, as the entity states it. + pub elements: i64, + /// Groups 10/20/30. + pub start: Point, + pub vertices: Vec, +} + /// TEXT, and the text of an ATTRIB or ATTDEF. #[derive(Clone, Debug, PartialEq)] pub struct Text { @@ -223,12 +280,22 @@ pub struct Text { pub generation: i64, /// Group 72: 0 left, 1 centre, 2 right, 3 aligned, 4 middle, 5 fit. pub halign: i64, - /// Group 73 (74 in an ATTRIB or ATTDEF): 0 baseline, 1 bottom, 2 middle, 3 top. + /// Group 73 (74 in an ATTRIB or ATTDEF, whose 73 is the field length): 0 baseline, 1 bottom, + /// 2 middle, 3 top. pub valign: i64, } impl Text { + /// The typed form of TEXT groups. pub fn read(g: &Groups<'_>) -> Text { + Self::read_as(g, false) + } + + /// A TEXT, or (with `attribute`) the text of an ATTRIB or ATTDEF. The vertical alignment is + /// group 73 for TEXT and group 74 for attributes, whose group 73 is the field length: a + /// writer that emits the field length would otherwise turn every attribute into baseline + /// text. + pub fn read_as(g: &Groups<'_>, attribute: bool) -> Text { Text { insert: g.point(10), align: g.point_opt(11), @@ -240,7 +307,7 @@ impl Text { style: g.name(7).unwrap_or_else(|| "Standard".into()), generation: g.i_or(71, 0), halign: g.i_or(72, 0), - valign: g.i(73).or_else(|| g.i(74)).unwrap_or(0), + valign: g.i_or(if attribute { 74 } else { 73 }, 0), } } } @@ -270,9 +337,17 @@ impl Attrib { let sub = own.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim().starts_with("AcDbAttribute"))).unwrap_or(0); let rest = own.get(sub..).unwrap_or_default(); let lock_position = rest.iter().position(|t| t.code == 2).is_some_and(|at| rest.iter().skip(at + 1).any(|t| t.code == 280 && t.i64() != 0)); + // The text is described by the groups up to the attribute's own subclass, and from there + // by all but the field length (73) and a multiline attribute's header (71, 72 and the + // point 11): there 72 isn't the justification nor 11 the alignment point, and a + // left-justified attribute writes no 72 of its own, so the multiline one was read as + // centred. + let at = own.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim().starts_with("AcDbAttribute"))).unwrap_or(own.len()); + let (head, tail) = own.split_at(at); + let text_groups: Vec = head.iter().chain(tail.iter().filter(|t| !matches!(t.code, 71 | 72 | 73 | 11 | 21 | 31))).cloned().collect(); Attrib { tag: g.s(2).unwrap_or_default(), - text: Text::read(&g), + text: Text::read_as(&Groups(&text_groups), true), prompt: if kind == "ATTDEF" { decode_line(&g.s(3).unwrap_or_default()) } else { String::new() }, flags: g.i_or(70, 0), lock_position, @@ -454,6 +529,7 @@ pub enum EntityKind { Polyline(Polyline), Spline(Spline), Helix(Helix), + MLine(MLine), Ray { base: Point, direction: Point, @@ -549,6 +625,50 @@ fn polyline(g: &Groups<'_>, followers: &[Record<'_>]) -> Polyline { } } +fn mline(tags: &[Tag], g: &Groups<'_>) -> MLine { + let mut vertices: Vec = Vec::new(); + // Which list the 41s or 42s go to: the last one a 74 or 75 started. + let mut fill = false; + for t in tags { + if t.code == 11 { + vertices.push(MLineVertex { location: [t.f64(), 0.0, 0.0], ..MLineVertex::default() }); + continue; + } + let Some(v) = vertices.last_mut() else { continue }; + let axis = match t.code / 10 { + 1 => 0, + 2 => 1, + _ => 2, + }; + match t.code { + 21 | 31 => v.location[axis] = t.f64(), + 12 | 22 | 32 => v.direction[axis] = t.f64(), + 13 | 23 | 33 => v.miter[axis] = t.f64(), + 74 => { + v.element_params.push(Vec::new()); + fill = false; + } + 75 => { + v.fill_params.push(Vec::new()); + fill = true; + } + 41 if !fill => v.element_params.last_mut().into_iter().for_each(|l| l.push(t.f64())), + 42 if fill => v.fill_params.last_mut().into_iter().for_each(|l| l.push(t.f64())), + _ => {} + } + } + MLine { + style: g.s(2).unwrap_or_default(), + style_handle: g.handle(340).filter(|h| *h != 0), + scale: g.f_or(40, 1.0), + justification: g.i_or(70, 0), + flags: g.i_or(71, 1), + elements: g.i_or(73, 0), + start: g.point(10), + vertices, + } +} + fn spline(g: &Groups<'_>) -> Spline { Spline { degree: g.i_or(71, 3), @@ -672,6 +792,11 @@ impl Entity { constrain: h.i_or(280, 2), }) } + "MLINE" => { + let own = g.subclass("AcDbMline"); + let own = if own.0.is_empty() { g } else { own }; + EntityKind::MLine(mline(own.0, &own)) + } "RAY" => EntityKind::Ray { base: line(&g).0, direction: line(&g).1 }, "XLINE" => EntityKind::XLine { base: line(&g).0, direction: line(&g).1 }, "TEXT" => EntityKind::Text(Text::read(&g)), diff --git a/crates/dxf/src/model/write.rs b/crates/dxf/src/model/write.rs index 4a1b11add..eaa8218e5 100644 --- a/crates/dxf/src/model/write.rs +++ b/crates/dxf/src/model/write.rs @@ -809,6 +809,37 @@ impl W { self.i(290, i64::from(h.ccw)); self.i(280, h.constrain); } + EntityKind::MLine(m) => { + let n = |v: usize| i64::try_from(v).unwrap_or(0); + self.sub("AcDbMline"); + self.s(2, m.style.as_str()); + self.h(340, m.style_handle.unwrap_or(0)); + self.f(40, m.scale); + self.i(70, m.justification); + self.i(71, m.flags); + self.i(72, n(m.vertices.len())); + self.i(73, m.elements); + self.p(10, m.start); + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + for v in &m.vertices { + self.p(11, v.location); + self.p(12, v.direction); + self.p(13, v.miter); + for (k, params) in v.element_params.iter().enumerate() { + self.i(74, n(params.len())); + for x in params { + self.f(41, *x); + } + let fill = v.fill_params.get(k).map_or(&[][..], Vec::as_slice); + self.i(75, n(fill.len())); + for x in fill { + self.f(42, *x); + } + } + } + } EntityKind::Ray { base, direction } | EntityKind::XLine { base, direction } => { self.sub(if matches!(kind, EntityKind::Ray { .. }) { "AcDbRay" } else { "AcDbXline" }); self.p(10, *base); @@ -884,6 +915,7 @@ fn kind_name(kind: &EntityKind) -> Option<&'static str> { EntityKind::Polyline(_) => "POLYLINE", EntityKind::Spline(_) => "SPLINE", EntityKind::Helix(_) => "HELIX", + EntityKind::MLine(_) => "MLINE", EntityKind::Ray { .. } => "RAY", EntityKind::XLine { .. } => "XLINE", EntityKind::Text(_) => "TEXT", diff --git a/crates/dxf/tests/model_entities.rs b/crates/dxf/tests/model_entities.rs index 5de59761b..ad20e81c4 100644 --- a/crates/dxf/tests/model_entities.rs +++ b/crates/dxf/tests/model_entities.rs @@ -391,3 +391,46 @@ fn a_spline_edge_without_fit_data_leaves_the_loops_source_count() { matches!(&h.loops[0].path, HatchPath::Edges(e) if matches!(&e[0], HatchEdge::Spline { fit, control, .. } if fit.is_empty() && control.len() == 2)) ); } + +#[test] +fn an_attributes_vertical_alignment_is_group_74_not_the_field_length() { + // 73 is the field length of an ATTRIB or ATTDEF; a writer that emits it must not turn a + // middle-aligned attribute into another alignment. + let EntityKind::AttDef(a) = one("0|ATTDEF|10|1.0|1|x|2|TAG|70|0|73|8|74|2").kind else { panic!("an attribute definition") }; + assert_eq!(a.text.valign, 2); + let EntityKind::AttDef(a) = one("0|ATTDEF|10|1.0|1|x|2|TAG|70|0|73|3").kind else { panic!("an attribute definition") }; + assert_eq!(a.text.valign, 0, "a field length alone is no alignment"); + let EntityKind::Text(t) = one("0|TEXT|10|1.0|1|x|73|3|74|1").kind else { panic!("text") }; + assert_eq!(t.valign, 3, "TEXT has its alignment in 73"); +} + +#[test] +fn entity_groups_written_after_the_entitys_own_marker() { + // An AcDbEntity subclass without a layer: the run of entity codes after the LINE's own marker + // is the entity's. + let c = one("0|LINE|5|2A|100|AcDbEntity|100|AcDbLine|8|Walls|62|1|370|50|10|1.0|20|2.0|48|9.0").common; + assert_eq!((c.layer.as_str(), c.color, c.lineweight), ("Walls", Color::Index(1), Lineweight::Hundredths(50))); + assert_eq!(c.ltscale, 1.0, "the run ends at the entity's own first group"); + // With a layer in AcDbEntity, nothing after the entity's marker is taken. + let c = one("0|MTEXT|100|AcDbEntity|8|A|100|AcDbMText|62|3|48|7.0").common; + assert_eq!((c.layer.as_str(), c.color, c.ltscale), ("A", Color::ByLayer, 1.0)); +} + +#[test] +fn an_attributes_justification_is_not_its_multiline_header() { + // A left-justified multiline attribute writes no 72 of its own; the 72 and 11 of its + // AcDbAttribute subclass are the multiline header. + let EntityKind::AttDef(a) = one( + "0|ATTDEF|100|AcDbText|10|1.0|20|2.0|40|2.5|1|x|100|AcDbAttributeDefinition|280|0|3|p|2|TAG|70|0|74|0|280|0|71|4|72|1|11|9.0|21|9.0|31|0.0|101|Embedded Object|10|1.0|1|x", + ) + .kind + else { + panic!("an attribute definition") + }; + assert_eq!((a.text.halign, a.text.valign, a.text.align, a.text.generation), (0, 0, None, 0)); + // The text's own justification still counts. + let EntityKind::AttDef(a) = one("0|ATTDEF|100|AcDbText|10|1.0|1|x|72|2|11|5.0|100|AcDbAttributeDefinition|2|TAG|74|3").kind else { + panic!("an attribute definition") + }; + assert_eq!((a.text.halign, a.text.valign, a.text.align), (2, 3, Some([5.0, 0.0, 0.0]))); +} diff --git a/crates/dxf/tests/model_objects.rs b/crates/dxf/tests/model_objects.rs index 976805bf8..8053f3422 100644 --- a/crates/dxf/tests/model_objects.rs +++ b/crates/dxf/tests/model_objects.rs @@ -133,3 +133,22 @@ fn helix() { assert_eq!((h.axis_base, h.start, h.axis), ([0.5, 0.5, 0.0], [1.5, 0.5, 0.0], [0.0, 0.0, 1.0])); assert_eq!((h.radius, h.turns, h.turn_height, h.ccw, h.constrain), (1.0, 3.0, 2.0, false, 1)); } + +#[test] +fn mline() { + let t = tags("0|SECTION|2|ENTITIES|0|MLINE|100|AcDbEntity|8|0|100|AcDbMline|2|WALL|340|41|40|2.0|70|1|71|3|72|2|73|2|10|0.0|20|0.0|30|0.0|210|0.0|220|0.0|230|1.0 + 11|0.0|21|0.0|31|0.0|12|1.0|22|0.0|32|0.0|13|0.0|23|1.0|33|0.0|74|3|41|0.5|41|1.0|41|2.0|75|0|74|1|41|-0.5|75|1|42|7.0 + 11|4.0|21|0.0|31|0.0|12|1.0|22|0.0|32|0.0|13|0.0|23|1.0|33|0.0|74|1|41|0.5|75|0|74|1|41|-0.5|75|0|0|ENDSEC|0|EOF"); + let d = Document::read(&t); + let EntityKind::MLine(m) = Entity::read(&d.entities[0]).kind else { panic!("a multiline") }; + assert_eq!( + (m.style.as_str(), m.style_handle, m.scale, m.justification, m.flags, m.elements, m.start), + ("WALL", Some(0x41), 2.0, 1, 3, 2, [0.0; 3]) + ); + assert_eq!(m.vertices.len(), 2); + let v = &m.vertices[0]; + assert_eq!((v.location, v.direction, v.miter), ([0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0])); + assert_eq!(v.element_params, [vec![0.5, 1.0, 2.0], vec![-0.5]]); + assert_eq!(v.fill_params, [vec![], vec![7.0]], "42s go to the list their 75 started"); + assert_eq!(m.vertices[1].location, [4.0, 0.0, 0.0]); +} diff --git a/crates/dxf/tests/model_write.rs b/crates/dxf/tests/model_write.rs index d20b02dcb..567e3528a 100644 --- a/crates/dxf/tests/model_write.rs +++ b/crates/dxf/tests/model_write.rs @@ -6,8 +6,8 @@ use craft_dxf::model::text::{MText, MTextColumns}; use craft_dxf::model::write::{BlockDef, Drawing, Format, entity_tags, fixed}; use craft_dxf::model::{ Attrib, Color, Common, DashElement, Dimension, Document, Entity, EntityKind, Gradient, Hatch, HatchEdge, HatchLoop, HatchPath, Helix, Image, - Insert, Layer, Leader, Linetype, Lineweight, LwPolyline, LwVertex, PatternLine, Polyline, Spline, Text, TextStyle, Transparency, Vertex, - Viewport, records, + Insert, Layer, Leader, Linetype, Lineweight, LwPolyline, LwVertex, MLine, MLineVertex, PatternLine, Polyline, Spline, Text, TextStyle, + Transparency, Vertex, Viewport, records, }; use craft_dxf::{Tag, parse, write_ascii_as}; @@ -257,6 +257,31 @@ fn sample(full: bool) -> Vec { ccw: false, constrain: 1, })), + e(EntityKind::MLine(MLine { + style: "WALL".into(), + style_handle: Some(0x41), + scale: 2.0, + justification: 1, + flags: 1 | 4, + elements: 2, + start: p(0.0), + vertices: vec![ + MLineVertex { + location: p(0.0), + direction: [1.0, 0.0, 0.0], + miter: [0.0, 1.0, 0.0], + element_params: vec![vec![0.5, 1.0, 2.0], vec![-0.5]], + fill_params: vec![vec![], vec![]], + }, + MLineVertex { + location: p(4.0), + direction: [1.0, 0.0, 0.0], + miter: [0.0, 1.0, 0.0], + element_params: vec![vec![0.5], vec![-0.5]], + fill_params: vec![vec![], vec![]], + }, + ], + })), e(EntityKind::Ray { base: p(1.0), direction: [0.0, 1.0, 0.0] }), e(EntityKind::XLine { base: p(1.0), direction: [1.0, 0.0, 0.0] }), e(EntityKind::Text(text("10^2\tm"))), @@ -394,8 +419,8 @@ fn owners_are_the_space_the_entity_is_in() { let back = read_back(&tags); assert_eq!(back[0].common.owner, Some(fixed::PAPER_RECORD), "a paper space entity"); assert_eq!(back[1].common.owner, Some(fixed::MODEL_RECORD)); - let EntityKind::Insert(i) = &back[18].kind else { panic!("an insert") }; - assert_eq!(i.attribs[0].0.owner, back[18].common.handle, "an attribute is owned by its insert"); + let EntityKind::Insert(i) = &back[19].kind else { panic!("an insert") }; + assert_eq!(i.attribs[0].0.owner, back[19].common.handle, "an attribute is owned by its insert"); assert_eq!((back[0].common.handle, i.attribs[0].0.handle), (Some(0x500), Some(0x501)), "given handles are kept"); } @@ -613,7 +638,7 @@ fn the_file_around_the_content() { #[test] fn one_entity_for_a_file_the_caller_writes() { - let insert = sample(true).remove(18); + let insert = sample(true).remove(19); let mut next = 0x10; let tags = entity_tags(&Entity { common: Common { handle: Some(0x5), ..insert.common.clone() }, ..insert.clone() }, Format::R2018, 0x1F, &mut next);