From 491fb0095fa61242572d7c8c48d2dc1dbe44b3eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:03:30 -0500 Subject: [PATCH] craft-dxf: model::write, a drawing as tags (R12 to 2018) (#947) * craft-dxf: model, the drawing above the tags (header, tables, blocks, layouts) Document::read indexes a tag list into header variables, table records, blocks with their entities, the ENTITIES records (a POLYLINE with its vertices, an INSERT with its attributes) and the objects, each a slice of the tag list. The typed table records, LAYOUT and DICTIONARY are made on request. Also the colour index palette, colour, lineweight and transparency values, extended data lookup and the object coordinate system. Merged from CADCraft's cadcraft-io dxf_read (5a6aa9b6) and VectorCraft's vectorcraft-cad import (566f7208). Neither app's document types appear. * craft-dxf: model entities (geometry, text, MTEXT columns, hatches, dimensions) Entity::read types an entity record: the properties every entity has, and LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE, POLYLINE with its vertices (2D, 3D, mesh, polyface), SPLINE, RAY, XLINE, TEXT, MTEXT with columns, ATTDEF, INSERT with its attributes (multiline ones included), DIMENSION, LEADER, SOLID, TRACE, 3DFACE, HATCH (polyline and edge loops, pattern lines, seeds, gradient), VIEWPORT, IMAGE, WIPEOUT and TOLERANCE. Other types stay readable from the record's tags. model::text has the caret encoding of strings, both ways. Merged from CADCraft's cadcraft-io dxf_read (5a6aa9b6) and VectorCraft's vectorcraft-cad import (566f7208). * craft-dxf: a hatch spline edge without fit data leaves the loop's source count Before R2010 a spline edge has no 97 of its own, so the 97 after it counts the loop's source entities. It was read as the edge's fit point count and the sources were lost. * craft-dxf: model::write, a drawing as tags (R12 to 2018) Drawing::tags writes the header, classes, the nine symbol tables, blocks, entities and (R2000 and later) the dictionaries, plot style placeholder and layouts around the layers, linetypes, styles, dimension styles, blocks and entities it is given, in the model's own types. entity_tags writes one entity for a caller with its own file. Handles given with the content are kept; the rest are numbered after them. The file skeleton is VectorCraft's vectorcraft-cad writer.rs (566f7208); the MTEXT column groups are CADCraft's dxf_read/mtext_dxf.rs column_tags (5a6aa9b6). * README: keep main's crate list after the merge --- README.md | 2 +- crates/dxf/src/model.rs | 1 + crates/dxf/src/model/write.rs | 1553 +++++++++++++++++++++++++++++++ crates/dxf/tests/model_write.rs | 606 ++++++++++++ 4 files changed, 2161 insertions(+), 1 deletion(-) create mode 100644 crates/dxf/src/model/write.rs create mode 100644 crates/dxf/tests/model_write.rs diff --git a/README.md b/README.md index 50a114aa..78db406e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " &'static str { + match self { + Format::R12 => "AC1009", + Format::R13 => "AC1012", + Format::R14 => "AC1014", + Format::R2000 => "AC1015", + Format::R2004 => "AC1018", + Format::R2007 => "AC1021", + Format::R2010 => "AC1024", + Format::R2013 => "AC1027", + Format::R2018 => "AC1032", + } + } + /// Objects have handles, owners and subclass markers. + pub fn handles(self) -> bool { + self >= Format::R13 + } + pub fn lwpolyline(self) -> bool { + self >= Format::R14 + } + pub fn r2000(self) -> bool { + self >= Format::R2000 + } + pub fn true_color(self) -> bool { + self >= Format::R2004 + } + /// Text is UTF-8: pass this to [`crate::write_ascii_as`]. + pub fn utf8(self) -> bool { + self >= Format::R2007 + } +} + +impl From for Format { + fn from(v: Version) -> Format { + match v { + Version::R2000 => Format::R2000, + Version::R2004 => Format::R2004, + Version::R2007 => Format::R2007, + Version::R2010 => Format::R2010, + Version::R2013 => Format::R2013, + Version::R2018 => Format::R2018, + } + } +} + +/// The handles of the objects every drawing has. Handles given with the content are kept when +/// they are [`fixed::FIRST_FREE`] or more and not taken; anything else gets a new one. +pub mod fixed { + pub const LAYER_TABLE: u64 = 0x1; + pub const LTYPE_TABLE: u64 = 0x2; + pub const APPID_TABLE: u64 = 0x3; + pub const DIMSTYLE_TABLE: u64 = 0x4; + pub const STYLE_TABLE: u64 = 0x5; + pub const UCS_TABLE: u64 = 0x6; + pub const VIEW_TABLE: u64 = 0x7; + pub const VPORT_TABLE: u64 = 0x8; + pub const BLOCK_RECORD_TABLE: u64 = 0x9; + pub const ROOT: u64 = 0xA; + pub const GROUPS: u64 = 0xB; + pub const LAYOUTS: u64 = 0xC; + pub const MODEL_RECORD: u64 = 0xD; + pub const PAPER_RECORD: u64 = 0xE; + pub const MODEL_BLOCK: u64 = 0xF; + pub const MODEL_END: u64 = 0x10; + pub const PAPER_BLOCK: u64 = 0x11; + pub const PAPER_END: u64 = 0x12; + pub const MODEL_LAYOUT: u64 = 0x13; + pub const PAPER_LAYOUT: u64 = 0x14; + pub const ACTIVE_VPORT: u64 = 0x15; + pub const ACAD_APPID: u64 = 0x16; + pub const STANDARD_DIMSTYLE: u64 = 0x17; + pub const PLOT_STYLES: u64 = 0x1A; + pub const PLOT_NORMAL: u64 = 0x1B; + pub const BYBLOCK_LTYPE: u64 = 0x1C; + pub const BYLAYER_LTYPE: u64 = 0x1D; + pub const CONTINUOUS_LTYPE: u64 = 0x1E; + pub const LAYER_0: u64 = 0x1F; + pub const STANDARD_STYLE: u64 = 0x20; + pub const FIRST_FREE: u64 = 0x30; +} + +/// Tags in the order they are written. +struct W { + v: Format, + t: Vec, +} + +impl W { + fn s(&mut self, code: i32, v: impl Into) { + self.t.push(Tag::s(code, v)); + } + fn f(&mut self, code: i32, v: f64) { + self.t.push(Tag::f(code, v)); + } + fn i(&mut self, code: i32, v: i64) { + self.t.push(Tag::i(code, v)); + } + fn h(&mut self, code: i32, h: u64) { + self.s(code, format!("{h:X}")); + } + fn xy(&mut self, code: i32, p: [f64; 2]) { + self.f(code, p[0]); + self.f(code + 10, p[1]); + } + fn p(&mut self, code: i32, p: Point) { + self.xy(code, [p[0], p[1]]); + self.f(code + 20, p[2]); + } + /// A subclass marker (R13 and later). + fn sub(&mut self, marker: &str) { + if self.v.handles() { + self.s(100, marker); + } + } + /// The start of an object: its type, and from R13 its handle, owner and first subclass. + fn object(&mut self, kind: &str, h: u64, owner: u64, subclass: &str) { + self.s(0, kind); + if self.v.handles() { + self.h(if kind == "DIMSTYLE" { 105 } else { 5 }, h); + self.h(330, owner); + self.s(100, subclass); + } + } + /// A table record: the shared record subclass, then the table's own. + fn record(&mut self, kind: &str, h: u64, table: u64, subclass: &str) { + self.object(kind, h, table, "AcDbSymbolTableRecord"); + self.sub(subclass); + } + fn table(&mut self, name: &str, h: u64, count: usize) { + self.s(0, "TABLE"); + self.s(2, name); + if self.v.handles() { + self.h(5, h); + self.s(330, "0"); + self.s(100, "AcDbSymbolTable"); + } + self.i(70, i64::try_from(count).unwrap_or(i64::MAX)); + if name == "DIMSTYLE" && self.v.r2000() { + self.s(100, "AcDbDimStyleTable"); + } + } + fn end_table(&mut self) { + self.s(0, "ENDTAB"); + } + fn section(&mut self, name: &str) { + self.s(0, "SECTION"); + self.s(2, name); + } + fn end_section(&mut self) { + self.s(0, "ENDSEC"); + } + /// The thickness and extrusion of a planar entity, when they are not the defaults. + fn planar(&mut self, c: &Common) { + if c.thickness != 0.0 { + self.f(39, c.thickness); + } + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + } + + /// The start of an entity: type, handle, owner and the properties every entity has. + fn common(&mut self, kind: &str, c: &Common, h: u64, owner: u64) { + self.object(kind, h, owner, "AcDbEntity"); + if c.paper_space { + self.i(67, 1); + } + self.s(8, c.layer.as_str()); + if let Some(lt) = &c.linetype { + self.s(6, lt.as_str()); + } + match c.color { + Color::ByLayer => {} + Color::ByBlock => self.i(62, 0), + Color::Index(i) => self.i(62, i64::from(i)), + Color::Rgb(rgb) => { + // The nearest index for readers (and versions) without true colour. + self.i(62, i64::from(aci::nearest(rgb, 1..=255))); + if self.v.true_color() { + self.i(420, i64::from(u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]]))); + } + } + } + if c.ltscale != 1.0 { + self.f(48, c.ltscale); + } + if !c.visible { + self.i(60, 1); + } + if self.v.r2000() && c.lineweight != Lineweight::ByLayer { + self.i(370, c.lineweight.to_dxf()); + } + if self.v.true_color() && c.transparency != Transparency::ByLayer { + self.i(440, c.transparency.to_dxf()); + } + if let Some(ps) = c.plot_style.filter(|_| self.v.r2000()) { + self.h(390, ps); + } + } + + fn text(&mut self, t: &Text, c: &Common, attribute: bool) { + self.sub("AcDbText"); + self.p(10, t.insert); + self.f(40, t.height); + self.s(1, encode_line(&t.value)); + self.f(50, t.rotation); + self.f(41, t.width_factor); + self.f(51, t.oblique); + self.s(7, t.style.as_str()); + self.i(71, t.generation); + self.i(72, t.halign); + if let Some(a) = t.align { + self.p(11, a); + } + self.planar(c); + // An attribute's vertical alignment is group 74 of its own subclass. + if !attribute { + self.sub("AcDbText"); + self.i(73, t.valign); + } + } + + fn attrib(&mut self, kind: &str, a: &Attrib, c: &Common) { + self.text(&a.text, c, true); + self.sub(if kind == "ATTDEF" { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); + if self.v.r2000() { + self.i(280, 0); + } + if kind == "ATTDEF" { + self.s(3, encode_line(&a.prompt)); + } + self.s(2, a.tag.as_str()); + self.i(70, a.flags); + self.i(74, a.text.valign); + if self.v.r2000() { + self.i(280, i64::from(a.lock_position)); + } + // The embedded MTEXT of a multiline attribute (R2018; older versions keep the one line). + if let Some(m) = a.multiline.as_ref().filter(|_| self.v >= Format::R2018) { + self.s(101, "Embedded Object"); + self.mtext_groups(m); + } + } + + fn mtext_groups(&mut self, m: &MText) { + self.p(10, m.insert); + self.f(40, m.height); + self.f(41, m.width); + self.i(71, i64::from(m.attach)); + self.i(72, m.drawing_direction); + let mut chunks = string_groups(&encode_contents(&m.contents)); + let last = chunks.pop().unwrap_or_default(); + for chunk in chunks { + self.s(3, chunk); + } + self.s(1, last); + self.s(7, m.style.as_str()); + match m.direction { + Some(d) => self.p(11, d), + None => self.f(50, m.rotation), + } + self.i(73, if m.line_spacing_exact { 2 } else { 1 }); + self.f(44, m.line_spacing); + if let Some((flags, color, offset)) = m.background { + self.i(90, flags); + match color { + Color::Rgb(rgb) => self.i(421, i64::from(u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]]))), + Color::Index(i) => self.i(63, i64::from(i)), + Color::ByBlock => self.i(63, 0), + Color::ByLayer => self.i(63, 256), + } + self.f(45, offset); + } + } + + /// The column groups of an MTEXT: the legacy groups before R2018, an embedded object from + /// R2018 on. + fn mtext_columns(&mut self, m: &MText) { + let Some(c) = &m.columns else { return }; + let manual = c.dynamic && !c.auto_height; + let heights: Vec = if manual { c.heights.clone() } else { vec![c.height; usize::from(c.count.max(1))] }; + let count = if manual { heights.len().max(1) } else { usize::from(c.count.max(1)) }; + let n = i64::try_from(count).unwrap_or(1); + let kind = if c.dynamic { 2 } else { 1 }; + if self.v < Format::R2018 { + self.i(75, kind); + self.i(76, n); + self.i(78, i64::from(c.flow_reversed)); + self.i(79, i64::from(c.auto_height)); + self.f(48, c.width); + self.f(49, c.gutter); + // No count for an empty list: a lone 50 = 0 would read back as one height of 0. + if !heights.is_empty() { + self.i(50, i64::try_from(heights.len()).unwrap_or(0)); + } + for h in heights { + self.f(50, h); + } + return; + } + let height = if manual { heights.iter().copied().fold(0.0, f64::max) } else { c.height }; + let (sin, cos) = m.angle().sin_cos(); + #[expect(clippy::cast_precision_loss, reason = "a column count is at most 1,000")] + let total = c.width * count as f64 + c.gutter * (count as f64 - 1.0); + self.f(46, height); + self.s(101, "Embedded Object"); + self.i(70, 1); + self.p(10, [cos, sin, 0.0]); + self.p(11, m.insert); + self.f(40, c.width); + self.f(41, height); + self.f(42, total); + self.f(43, height); + self.i(71, kind); + self.i(72, n); + self.f(44, c.width); + self.f(45, c.gutter); + self.i(73, i64::from(c.auto_height)); + self.i(74, i64::from(c.flow_reversed)); + if manual { + for h in heights { + self.f(46, h); + } + } + } + + fn lwpolyline(&mut self, p: &LwPolyline, c: &Common) { + self.sub("AcDbPolyline"); + self.i(90, i64::try_from(p.vertices.len()).unwrap_or(0)); + self.i(70, i64::from(p.closed) | if p.plinegen { 128 } else { 0 }); + self.f(43, p.const_width); + self.f(38, p.elevation); + if c.thickness != 0.0 { + self.f(39, c.thickness); + } + for v in &p.vertices { + self.xy(10, [v.x, v.y]); + if v.start_width != 0.0 || v.end_width != 0.0 { + self.f(40, v.start_width); + self.f(41, v.end_width); + } + if v.bulge != 0.0 { + self.f(42, v.bulge); + } + } + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + } + + fn spline(&mut self, s: &Spline) { + let n = |v: usize| i64::try_from(v).unwrap_or(0); + self.sub("AcDbSpline"); + self.i(70, s.flags); + self.i(71, s.degree); + self.i(72, n(s.knots.len())); + self.i(73, n(s.control.len())); + self.i(74, n(s.fit.len())); + self.f(42, 1e-7); + self.f(43, 1e-7); + self.f(44, s.fit_tolerance); + if let Some(t) = s.start_tangent { + self.p(12, t); + } + if let Some(t) = s.end_tangent { + self.p(13, t); + } + for k in &s.knots { + self.f(40, *k); + } + for (i, p) in s.control.iter().enumerate() { + self.p(10, *p); + if let Some(w) = s.weights.get(i).filter(|_| s.weights.len() == s.control.len()) { + self.f(41, *w); + } + } + for p in &s.fit { + self.p(11, *p); + } + } + + fn dimension(&mut self, d: &Dimension, c: &Common) { + self.sub("AcDbDimension"); + if let Some(b) = &d.block { + self.s(2, b.as_str()); + } + self.p(10, d.defpt); + self.p(11, d.text_mid); + self.p(12, d.insertion); + self.i(70, d.flags); + self.s(1, encode_line(&d.text)); + if let Some(m) = d.measurement { + self.f(42, m); + } + self.s(3, d.style.as_str()); + if d.text_rotation != 0.0 { + self.f(53, d.text_rotation); + } + if d.horizontal != 0.0 { + self.f(51, d.horizontal); + } + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + match d.kind() { + 0 | 1 => { + self.sub("AcDbAlignedDimension"); + self.p(13, d.p13); + self.p(14, d.p14); + if d.kind() == 0 { + self.f(50, d.rotation); + if d.oblique != 0.0 { + self.f(52, d.oblique); + } + self.sub("AcDbRotatedDimension"); + } + } + 2 => { + self.sub("AcDb2LineAngularDimension"); + for (code, p) in [(13, d.p13), (14, d.p14), (15, d.p15), (16, d.p16)] { + self.p(code, p); + } + } + 3 | 4 => { + self.sub(if d.kind() == 3 { "AcDbDiametricDimension" } else { "AcDbRadialDimension" }); + self.p(15, d.p15); + self.f(40, d.leader_length); + } + 5 => { + self.sub("AcDb3PointAngularDimension"); + for (code, p) in [(13, d.p13), (14, d.p14), (15, d.p15)] { + self.p(code, p); + } + } + _ => { + self.sub("AcDbOrdinateDimension"); + self.p(13, d.p13); + self.p(14, d.p14); + } + } + } + + fn leader(&mut self, l: &Leader) { + self.sub("AcDbLeader"); + self.s(3, l.style.as_str()); + self.i(71, i64::from(l.arrow)); + self.i(72, i64::from(l.spline)); + self.i(73, l.annotation_type); + self.i(76, i64::try_from(l.vertices.len()).unwrap_or(0)); + for v in &l.vertices { + self.p(10, *v); + } + if let Some(a) = l.annotation { + self.h(340, a); + } + } + + fn hatch_path(&mut self, path: &HatchPath) { + let n = |v: usize| i64::try_from(v).unwrap_or(0); + match path { + HatchPath::Polyline { closed, vertices } => { + let bulges = vertices.iter().any(|v| v[2] != 0.0); + self.i(72, i64::from(bulges)); + self.i(73, i64::from(*closed)); + self.i(93, n(vertices.len())); + for v in vertices { + self.xy(10, [v[0], v[1]]); + if bulges { + self.f(42, v[2]); + } + } + } + HatchPath::Edges(edges) => { + self.i(93, n(edges.len())); + for e in edges { + self.hatch_edge(e); + } + } + } + } + + fn hatch_edge(&mut self, e: &HatchEdge) { + let n = |v: usize| i64::try_from(v).unwrap_or(0); + match e { + HatchEdge::Line { start, end } => { + self.i(72, 1); + self.xy(10, *start); + self.xy(11, *end); + } + HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => { + self.i(72, 2); + self.xy(10, *center); + self.f(40, *radius); + self.f(50, *start_angle); + self.f(51, *end_angle); + self.i(73, i64::from(*ccw)); + } + HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => { + self.i(72, 3); + self.xy(10, *center); + self.xy(11, *major); + self.f(40, *ratio); + self.f(50, *start_angle); + self.f(51, *end_angle); + self.i(73, i64::from(*ccw)); + } + HatchEdge::Spline { degree, rational, periodic, knots, control, weights, fit, start_tangent, end_tangent } => { + self.i(72, 4); + self.i(94, *degree); + self.i(73, i64::from(*rational)); + self.i(74, i64::from(*periodic)); + self.i(95, n(knots.len())); + self.i(96, n(control.len())); + for k in knots { + self.f(40, *k); + } + for (i, p) in control.iter().enumerate() { + self.xy(10, *p); + if let Some(w) = weights.get(i).filter(|_| weights.len() == control.len()) { + self.f(42, *w); + } + } + // Fit data came with R2010. + if self.v >= Format::R2010 { + self.i(97, n(fit.len())); + for p in fit { + self.xy(11, *p); + } + if let Some(t) = start_tangent { + self.xy(12, *t); + } + if let Some(t) = end_tangent { + self.xy(13, *t); + } + } + } + } + } + + fn hatch(&mut self, h: &Hatch, c: &Common) { + let n = |v: usize| i64::try_from(v).unwrap_or(0); + self.sub("AcDbHatch"); + self.p(10, [0.0, 0.0, h.elevation]); + self.p(210, c.extrusion); + self.s(2, h.pattern.as_str()); + self.i(70, i64::from(h.solid)); + self.i(71, i64::from(h.associative)); + self.i(91, n(h.loops.len())); + for l in &h.loops { + self.i(92, l.flags); + self.hatch_path(&l.path); + self.i(97, n(l.sources.len())); + for s in &l.sources { + self.h(330, *s); + } + } + self.i(75, h.style); + self.i(76, h.pattern_type); + if !h.solid { + self.f(52, h.angle); + self.f(41, h.scale); + self.i(77, i64::from(h.double)); + self.i(78, n(h.pattern_lines.len())); + for l in &h.pattern_lines { + self.f(53, l.angle); + self.f(43, l.base[0]); + self.f(44, l.base[1]); + self.f(45, l.offset[0]); + self.f(46, l.offset[1]); + self.i(79, n(l.dashes.len())); + for d in &l.dashes { + self.f(49, *d); + } + } + } + self.i(98, n(h.seeds.len())); + for s in &h.seeds { + self.xy(10, *s); + } + if let Some(g) = h.gradient.as_ref().filter(|_| self.v.true_color()) { + self.i(450, 1); + self.i(451, 0); + self.f(460, g.angle); + self.f(461, g.shift); + self.i(452, i64::from(g.single)); + self.f(462, g.tint); + self.i(453, n(g.colors.len())); + for (i, color) in g.colors.iter().enumerate() { + #[expect(clippy::cast_precision_loss, reason = "a gradient has a handful of colours")] + self.f(463, i as f64); + match color { + Color::Rgb(rgb) => { + self.i(63, i64::from(aci::nearest(*rgb, 1..=255))); + self.i(421, i64::from(u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]]))); + } + Color::Index(i) => self.i(63, i64::from(*i)), + Color::ByBlock => self.i(63, 0), + Color::ByLayer => self.i(63, 256), + } + } + self.s(470, g.name.as_str()); + } + } + + fn viewport(&mut self, v: &Viewport) { + self.sub("AcDbViewport"); + self.p(10, v.center); + self.f(40, v.width); + self.f(41, v.height); + self.i(68, i64::from(v.status & 131_072 == 0)); + self.i(69, v.id); + self.xy(12, v.view_center); + self.p(16, v.view_direction); + self.p(17, v.view_target); + self.f(45, v.view_height); + self.f(51, v.twist); + for l in &v.frozen_layers { + self.h(331, *l); + } + self.i(90, v.status); + if let Some(c) = v.clip { + self.h(340, c); + } + } + + fn image(&mut self, kind: &str, i: &Image) { + self.sub(if kind == "WIPEOUT" { "AcDbWipeout" } else { "AcDbRasterImage" }); + self.i(90, 0); + self.p(10, i.insert); + self.p(11, i.u); + self.p(12, i.v); + self.xy(13, i.size); + if let Some(d) = i.image_def { + self.h(340, d); + } + self.i(70, i.display); + self.i(280, i64::from(i.clipping)); + self.i(281, i.brightness); + self.i(282, i.contrast); + self.i(283, i.fade); + self.i(71, i.clip_type); + self.i(91, i64::try_from(i.clip.len()).unwrap_or(0)); + for q in &i.clip { + self.xy(14, *q); + } + if self.v >= Format::R2010 { + self.i(290, i64::from(i.clip_inverted)); + } + } + + /// A POLYLINE's own groups; its vertices and SEQEND follow in [`Ids::entity`]. + fn polyline_head(&mut self, p: &Polyline, c: &Common) { + self.sub(polyline_subclass(p.flags).0); + self.i(66, 1); + self.p(10, [0.0, 0.0, p.elevation]); + self.i(70, p.flags); + if p.start_width != 0.0 || p.end_width != 0.0 { + self.f(40, p.start_width); + self.f(41, p.end_width); + } + if p.is_mesh() || p.is_polyface() { + self.i(71, p.m); + self.i(72, p.n); + } + if p.smooth != 0 { + self.i(75, p.smooth); + } + self.planar(c); + } + + fn insert_head(&mut self, i: &Insert, c: &Common) { + self.sub("AcDbBlockReference"); + if !i.attribs.is_empty() { + self.i(66, 1); + } + self.s(2, i.block.as_str()); + self.p(10, i.insert); + self.f(41, i.scale[0]); + self.f(42, i.scale[1]); + self.f(43, i.scale[2]); + self.f(50, i.rotation); + if i.columns > 1 || i.rows > 1 { + self.i(70, i.columns); + self.i(71, i.rows); + self.f(44, i.column_spacing); + self.f(45, i.row_spacing); + } + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + } + + fn corners(&mut self, corners: &[Point; 4]) { + for (code, p) in (10..).zip(corners) { + self.p(code, *p); + } + } + + /// The groups of an entity after its common properties; `false` for a kind that has none + /// here ([`EntityKind::Other`]). + fn geometry(&mut self, kind: &EntityKind, c: &Common) -> bool { + match kind { + EntityKind::Line { start, end } => { + self.sub("AcDbLine"); + self.p(10, *start); + self.p(11, *end); + self.planar(c); + } + EntityKind::Point { location, angle } => { + self.sub("AcDbPoint"); + self.p(10, *location); + self.planar(c); + if *angle != 0.0 { + self.f(50, *angle); + } + } + EntityKind::Circle { center, radius } => { + self.sub("AcDbCircle"); + self.p(10, *center); + self.f(40, *radius); + self.planar(c); + } + EntityKind::Arc { center, radius, start_angle, end_angle } => { + self.sub("AcDbCircle"); + self.p(10, *center); + self.f(40, *radius); + self.planar(c); + self.sub("AcDbArc"); + self.f(50, *start_angle); + self.f(51, *end_angle); + } + EntityKind::Ellipse { center, major, ratio, start, end } => { + self.sub("AcDbEllipse"); + self.p(10, *center); + self.p(11, *major); + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + self.f(40, *ratio); + self.f(41, *start); + self.f(42, *end); + } + EntityKind::LwPolyline(p) => self.lwpolyline(p, c), + EntityKind::Polyline(p) => self.polyline_head(p, c), + EntityKind::Spline(s) => self.spline(s), + EntityKind::Ray { base, direction } | EntityKind::XLine { base, direction } => { + self.sub(if matches!(kind, EntityKind::Ray { .. }) { "AcDbRay" } else { "AcDbXline" }); + self.p(10, *base); + self.p(11, *direction); + } + EntityKind::Text(t) => self.text(t, c, false), + EntityKind::MText(m) => { + self.sub("AcDbMText"); + self.mtext_groups(m); + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + self.mtext_columns(m); + } + EntityKind::AttDef(a) => self.attrib("ATTDEF", a, c), + EntityKind::Insert(i) => self.insert_head(i, c), + EntityKind::Dimension(d) => self.dimension(d, c), + EntityKind::Leader(l) => self.leader(l), + EntityKind::Solid { corners } | EntityKind::Trace { corners } => { + self.sub("AcDbTrace"); + self.corners(corners); + self.planar(c); + } + EntityKind::Face3d { corners, hidden_edges } => { + self.sub("AcDbFace"); + self.corners(corners); + if *hidden_edges != 0 { + self.i(70, *hidden_edges); + } + } + EntityKind::Hatch(h) => self.hatch(h, c), + EntityKind::Viewport(v) => self.viewport(v), + EntityKind::Image(i) => self.image("IMAGE", i), + EntityKind::Wipeout(i) => self.image("WIPEOUT", i), + EntityKind::Tolerance { insert, direction, style, text } => { + self.sub("AcDbFcf"); + self.s(3, style.as_str()); + self.p(10, *insert); + self.s(1, text.as_str()); + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + self.p(11, *direction); + } + EntityKind::Other => return false, + } + true + } +} + +/// The subclass markers of a POLYLINE with these flags, and of its vertices. +fn polyline_subclass(flags: i64) -> (&'static str, &'static str) { + if flags & 64 != 0 { + ("AcDbPolyFaceMesh", "AcDbPolyFaceMeshVertex") + } else if flags & 16 != 0 { + ("AcDbPolygonMesh", "AcDbPolygonMeshVertex") + } else if flags & 8 != 0 { + ("AcDb3dPolyline", "AcDb3dPolylineVertex") + } else { + ("AcDb2dPolyline", "AcDb2dVertex") + } +} + +/// The record type of an entity kind; `None` for [`EntityKind::Other`]. +fn kind_name(kind: &EntityKind) -> Option<&'static str> { + Some(match kind { + EntityKind::Line { .. } => "LINE", + EntityKind::Point { .. } => "POINT", + EntityKind::Circle { .. } => "CIRCLE", + EntityKind::Arc { .. } => "ARC", + EntityKind::Ellipse { .. } => "ELLIPSE", + EntityKind::LwPolyline(_) => "LWPOLYLINE", + EntityKind::Polyline(_) => "POLYLINE", + EntityKind::Spline(_) => "SPLINE", + EntityKind::Ray { .. } => "RAY", + EntityKind::XLine { .. } => "XLINE", + EntityKind::Text(_) => "TEXT", + EntityKind::MText(_) => "MTEXT", + EntityKind::AttDef(_) => "ATTDEF", + EntityKind::Insert(_) => "INSERT", + EntityKind::Dimension(_) => "DIMENSION", + EntityKind::Leader(_) => "LEADER", + EntityKind::Solid { .. } => "SOLID", + EntityKind::Trace { .. } => "TRACE", + EntityKind::Face3d { .. } => "3DFACE", + EntityKind::Hatch(_) => "HATCH", + EntityKind::Viewport(_) => "VIEWPORT", + EntityKind::Image(_) => "IMAGE", + EntityKind::Wipeout(_) => "WIPEOUT", + EntityKind::Tolerance { .. } => "TOLERANCE", + EntityKind::Other => return None, + }) +} + +/// An LWPOLYLINE as the POLYLINE versions before R14 have. +fn heavy(p: &LwPolyline) -> Polyline { + use super::entity::Vertex; + let vertices = p + .vertices + .iter() + .map(|v| Vertex { + location: [v.x, v.y, p.elevation], + bulge: v.bulge, + start_width: v.start_width, + end_width: v.end_width, + flags: 0, + face: [0; 4], + }) + .collect(); + Polyline { + flags: i64::from(p.closed) | if p.plinegen { 128 } else { 0 }, + elevation: p.elevation, + start_width: p.const_width, + end_width: p.const_width, + m: 0, + n: 0, + smooth: 0, + vertices, + } +} + +/// Handles: those given are kept when free, the rest are numbered after them. +struct Ids { + used: HashSet, + /// The handle tried next for content without one. + next: u64, + /// The least handle that content may keep. + floor: u64, +} + +impl Ids { + fn new(given: impl Iterator) -> Ids { + let mut next = fixed::FIRST_FREE; + for h in given { + next = next.max(h.saturating_add(1)); + } + Ids { used: (0..fixed::FIRST_FREE).collect(), next, floor: fixed::FIRST_FREE } + } + fn take(&mut self, given: Option) -> u64 { + if let Some(h) = given.filter(|h| *h >= self.floor && self.used.insert(*h)) { + return h; + } + while !self.used.insert(self.next) { + self.next = self.next.saturating_add(1); + } + self.next + } + + /// One entity with what follows it (vertices, attributes, SEQEND); `false` when its kind + /// can't be written. + fn entity(&mut self, w: &mut W, e: &Entity, owner: u64) -> bool { + let converted; + let kind = match &e.kind { + EntityKind::LwPolyline(p) if !w.v.lwpolyline() => { + converted = EntityKind::Polyline(heavy(p)); + &converted + } + k => k, + }; + let Some(name) = kind_name(kind) else { return false }; + let h = self.take(e.common.handle); + w.common(name, &e.common, h, owner); + w.geometry(kind, &e.common); + // The followers take the entity's layer; each has a handle and is owned by the entity. + let follower = Common { handle: None, ..e.common.clone() }; + match kind { + EntityKind::Polyline(p) => { + let (_, vertex) = polyline_subclass(p.flags); + for v in &p.vertices { + let vh = self.take(None); + w.common("VERTEX", &follower, vh, h); + w.sub(if v.is_face() { "AcDbFaceRecord" } else { "AcDbVertex" }); + if !v.is_face() { + w.sub(vertex); + } + w.p(10, v.location); + if v.start_width != 0.0 || v.end_width != 0.0 { + w.f(40, v.start_width); + w.f(41, v.end_width); + } + if v.bulge != 0.0 { + w.f(42, v.bulge); + } + w.i(70, v.flags); + for (code, ix) in (71..).zip(v.face).filter(|(_, ix)| *ix != 0) { + w.i(code, ix); + } + } + let sh = self.take(None); + w.common("SEQEND", &follower, sh, h); + } + EntityKind::Insert(i) if !i.attribs.is_empty() => { + for (c, a) in &i.attribs { + let ah = self.take(c.handle); + w.common("ATTRIB", c, ah, h); + w.attrib("ATTRIB", a, c); + } + let sh = self.take(None); + w.common("SEQEND", &follower, sh, h); + } + _ => {} + } + true + } +} + +/// The tags of one entity (with its vertices or attributes and their SEQEND) for `format`, owned +/// by `owner`: for a caller that writes its own file around them. Its handle is the entity's, or +/// one from `next_handle`; followers get handles from `next_handle` too, which is moved past +/// those used. Empty for [`EntityKind::Other`]. +pub fn entity_tags(e: &Entity, format: Format, owner: u64, next_handle: &mut u64) -> Vec { + let mut w = W { v: format, t: Vec::new() }; + let mut ids = Ids { used: HashSet::new(), next: *next_handle, floor: 0 }; + let written = ids.entity(&mut w, e, owner); + *next_handle = ids.next.saturating_add(1); + if written { w.t } else { Vec::new() } +} + +/// A block definition to write. +#[derive(Clone, Debug, Default)] +pub struct BlockDef { + pub name: String, + pub base: Point, + /// Group 4. + pub description: String, + pub entities: Vec, +} + +/// A drawing to write: the content, around which [`Drawing::tags`] writes the file. +#[derive(Clone, Debug)] +pub struct Drawing<'a> { + pub format: Format, + /// `$DWGCODEPAGE` (`ANSI_1252` when empty). Before R2007, write the tags with + /// [`crate::write_ascii_in`] in the same code page. + pub code_page: String, + /// `$INSUNITS` (R2000 and later). + pub insunits: i64, + /// `$MEASUREMENT`: metric (R2000 and later). + pub metric: bool, + /// `[x0, y0, x1, y1]` of the content: `$EXTMIN`/`$EXTMAX`, and the view the file opens at. + pub extents: [f64; 4], + /// `$LIMMIN`/`$LIMMAX`. + pub limits: [f64; 4], + /// More header variables: the name (with its `$`) and its groups, written after the ones + /// above. One named like a variable this writer writes replaces it. + pub header: Vec<(String, Vec)>, + /// Layers other than `0`. A layer's linetype is written as named. + pub layers: Vec, + /// Linetypes other than `ByBlock`, `ByLayer` and `Continuous`. + pub linetypes: Vec, + /// Text styles other than `Standard`. + pub styles: Vec, + /// Dimension styles other than `Standard`. + pub dim_styles: Vec>, + pub blocks: Vec, + /// The ENTITIES section: model space, and the paper space entities ([`Common::paper_space`]) + /// of the one layout. An entity's owner is set by the writer. + pub entities: Vec, + /// Records written after the entities as they are (types this model does not have). Their + /// handles must not collide with the content's: the writer does not read them. + pub raw_entities: Vec, + /// CLASS records and OBJECTS records written as they are, and the entries that name those + /// objects in the root dictionary (R13 and later). + pub raw_classes: Vec, + pub raw_objects: Vec, + pub root_entries: Vec<(String, u64)>, +} + +impl Default for Drawing<'_> { + fn default() -> Self { + Drawing { + format: Format::default(), + code_page: String::new(), + insunits: 0, + metric: true, + extents: [0.0, 0.0, 1.0, 1.0], + limits: [0.0, 0.0, 420.0, 297.0], + header: Vec::new(), + layers: Vec::new(), + linetypes: Vec::new(), + styles: Vec::new(), + dim_styles: Vec::new(), + blocks: Vec::new(), + entities: Vec::new(), + raw_entities: Vec::new(), + raw_classes: Vec::new(), + raw_objects: Vec::new(), + root_entries: Vec::new(), + } + } +} + +const MODEL: &str = "*Model_Space"; +const PAPER: &str = "*Paper_Space"; + +impl Drawing<'_> { + /// The whole file as tags, ending with `0 EOF`, and how many entities were left out + /// ([`EntityKind::Other`], which has no groups to write: pass those in `raw_entities`). + pub fn tags(&self) -> (Vec, usize) { + let v = self.format; + let mut w = W { v, t: Vec::new() }; + let entities = self.entities.iter().chain(self.blocks.iter().flat_map(|b| &b.entities)); + let attribs = entities.clone().filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.attribs.iter().filter_map(|(c, _)| c.handle)), + _ => None, + }); + let tables = self.layers.iter().map(|l| l.handle).chain(self.linetypes.iter().map(|l| l.handle)).chain(self.styles.iter().map(|s| s.handle)); + let given = entities + .filter_map(|e| e.common.handle) + .chain(attribs.flatten()) + .chain(tables.flatten()) + .chain(self.dim_styles.iter().filter_map(|d| d.handle)); + let mut ids = Ids::new(given.chain(self.root_entries.iter().map(|(_, h)| *h))); + // Table records first, so entities numbered after them never take a handle a record asks for. + let layer_h: Vec = self.layers.iter().map(|l| ids.take(l.handle)).collect(); + let ltype_h: Vec = self.linetypes.iter().map(|l| ids.take(l.handle)).collect(); + let style_h: Vec = self.styles.iter().map(|s| ids.take(s.handle)).collect(); + let dim_h: Vec = self.dim_styles.iter().map(|d| ids.take(d.handle)).collect(); + let block_h: Vec<[u64; 3]> = self.blocks.iter().map(|_| [ids.take(None), ids.take(None), ids.take(None)]).collect(); + + // The content first: the header's handle seed comes after every handle. + let mut body = W { v, t: Vec::new() }; + let mut skipped = 0usize; + if v.handles() { + body.section("BLOCKS"); + for (name, record, begin, end) in [ + (MODEL, fixed::MODEL_RECORD, fixed::MODEL_BLOCK, fixed::MODEL_END), + (PAPER, fixed::PAPER_RECORD, fixed::PAPER_BLOCK, fixed::PAPER_END), + ] { + block_begin(&mut body, name, [0.0; 3], "", begin, record); + block_end(&mut body, end, record); + } + } else if !self.blocks.is_empty() { + body.section("BLOCKS"); + } + for (b, [record, begin, end]) in self.blocks.iter().zip(&block_h) { + block_begin(&mut body, &b.name, b.base, &b.description, *begin, *record); + for e in &b.entities { + skipped += usize::from(!ids.entity(&mut body, e, *record)); + } + block_end(&mut body, *end, *record); + } + if v.handles() || !self.blocks.is_empty() { + body.end_section(); + } + body.section("ENTITIES"); + for e in &self.entities { + let owner = if e.common.paper_space { fixed::PAPER_RECORD } else { fixed::MODEL_RECORD }; + skipped += usize::from(!ids.entity(&mut body, e, owner)); + } + body.t.extend(self.raw_entities.iter().cloned()); + body.end_section(); + if v.handles() { + self.objects(&mut body); + } + + self.header(&mut w, ids.take(None)); + if v.handles() { + self.classes(&mut w); + } + w.section("TABLES"); + self.vport(&mut w); + self.ltypes(&mut w, <ype_h); + self.layer_table(&mut w, &layer_h); + self.style_table(&mut w, &style_h); + if v.handles() { + for (name, h) in [("VIEW", fixed::VIEW_TABLE), ("UCS", fixed::UCS_TABLE)] { + w.table(name, h, 0); + w.end_table(); + } + w.table("APPID", fixed::APPID_TABLE, 1); + w.record("APPID", fixed::ACAD_APPID, fixed::APPID_TABLE, "AcDbRegAppTableRecord"); + w.s(2, "ACAD"); + w.i(70, 0); + w.end_table(); + self.dim_style_table(&mut w, &dim_h); + w.table("BLOCK_RECORD", fixed::BLOCK_RECORD_TABLE, 2 + self.blocks.len()); + let user = self.blocks.iter().zip(&block_h).map(|(b, h)| (b.name.as_str(), h[0], None)); + for (name, h, layout) in + [(MODEL, fixed::MODEL_RECORD, Some(fixed::MODEL_LAYOUT)), (PAPER, fixed::PAPER_RECORD, Some(fixed::PAPER_LAYOUT))] + .into_iter() + .chain(user) + { + w.record("BLOCK_RECORD", h, fixed::BLOCK_RECORD_TABLE, "AcDbBlockTableRecord"); + w.s(2, name); + if v.r2000() { + w.h(340, layout.unwrap_or(0)); + w.i(70, 0); + w.i(280, 1); + w.i(281, 0); + } + } + w.end_table(); + } + w.end_section(); + w.t.append(&mut body.t); + w.s(0, "EOF"); + (w.t, skipped) + } + + fn header(&self, w: &mut W, handseed: u64) { + let v = self.format; + let [x0, y0, x1, y1] = self.extents; + let [lx0, ly0, lx1, ly1] = self.limits; + let code_page = if self.code_page.is_empty() { "ANSI_1252" } else { self.code_page.as_str() }; + let mut vars: Vec<(&str, Vec)> = vec![ + ("$ACADVER", vec![Tag::s(1, v.acadver())]), + ("$DWGCODEPAGE", vec![Tag::s(3, code_page)]), + ("$INSBASE", vec![Tag::f(10, 0.0), Tag::f(20, 0.0), Tag::f(30, 0.0)]), + ("$EXTMIN", vec![Tag::f(10, x0), Tag::f(20, y0), Tag::f(30, 0.0)]), + ("$EXTMAX", vec![Tag::f(10, x1), Tag::f(20, y1), Tag::f(30, 0.0)]), + ("$LIMMIN", vec![Tag::f(10, lx0), Tag::f(20, ly0)]), + ("$LIMMAX", vec![Tag::f(10, lx1), Tag::f(20, ly1)]), + ]; + if v.r2000() { + vars.push(("$INSUNITS", vec![Tag::i(70, self.insunits)])); + vars.push(("$MEASUREMENT", vec![Tag::i(70, i64::from(self.metric))])); + } + w.section("HEADER"); + let given = |name: &str| self.header.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)); + // The version and the handle seed are the writer's to say. + for (name, tags) in vars.into_iter().filter(|(n, _)| *n == "$ACADVER" || !given(n)) { + w.s(9, name); + w.t.extend(tags); + } + for (name, tags) in self.header.iter().filter(|(n, _)| !n.eq_ignore_ascii_case("$ACADVER") && !n.eq_ignore_ascii_case("$HANDSEED")) { + w.s(9, name.as_str()); + w.t.extend(tags.iter().cloned()); + } + if v.handles() { + w.s(9, "$HANDSEED"); + w.h(5, handseed); + } + w.end_section(); + } + + fn classes(&self, w: &mut W) { + w.section("CLASSES"); + if self.format.r2000() { + for (name, cpp) in + [("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault"), ("ACDBPLACEHOLDER", "AcDbPlaceHolder"), ("LAYOUT", "AcDbLayout")] + { + w.s(0, "CLASS"); + w.s(1, name); + w.s(2, cpp); + w.s(3, "ObjectDBX Classes"); + w.i(90, 0); + if self.format.true_color() { + w.i(91, 0); + } + w.i(280, 0); + w.i(281, 0); + } + } + w.t.extend(self.raw_classes.iter().cloned()); + w.end_section(); + } + + /// The active viewport, zoomed to the drawing's extents. + fn vport(&self, w: &mut W) { + let [x0, y0, x1, y1] = self.extents; + w.table("VPORT", fixed::VPORT_TABLE, 1); + w.record("VPORT", fixed::ACTIVE_VPORT, fixed::VPORT_TABLE, "AcDbViewportTableRecord"); + w.s(2, if self.format.handles() { "*Active" } else { "*ACTIVE" }); + w.i(70, 0); + w.xy(10, [0.0, 0.0]); + w.xy(11, [1.0, 1.0]); + w.xy(12, [(x0 + x1) / 2.0, (y0 + y1) / 2.0]); + w.xy(13, [0.0, 0.0]); + w.xy(14, [1.0, 1.0]); + w.xy(15, [10.0, 10.0]); + w.p(16, [0.0, 0.0, 1.0]); + w.p(17, [0.0; 3]); + let (width, height) = ((x1 - x0).abs(), (y1 - y0).abs()); + // The view's height, with a margin; its width follows from the aspect ratio. + w.f(40, (height.max(width / 1.5) * 1.1).max(1.0)); + w.f(41, 1.5); + w.f(42, 50.0); + for code in [43, 44, 50, 51] { + w.f(code, 0.0); + } + for (code, value) in [(71, 0), (72, 1000), (73, 1), (74, 3), (75, 0), (76, 0), (77, 0), (78, 0)] { + w.i(code, value); + } + if self.format.r2000() { + w.i(281, 0); + w.i(65, 0); + w.f(146, 0.0); + } + w.end_table(); + } + + fn ltypes(&self, w: &mut W, handles: &[u64]) { + let n = |v: usize| i64::try_from(v).unwrap_or(0); + w.table("LTYPE", fixed::LTYPE_TABLE, 3 + self.linetypes.len()); + for (name, desc, h) in + [("ByBlock", "", fixed::BYBLOCK_LTYPE), ("ByLayer", "", fixed::BYLAYER_LTYPE), ("Continuous", "Solid line", fixed::CONTINUOUS_LTYPE)] + { + w.record("LTYPE", h, fixed::LTYPE_TABLE, "AcDbLinetypeTableRecord"); + w.s(2, name); + w.i(70, 0); + w.s(3, desc); + w.i(72, 65); + w.i(73, 0); + w.f(40, 0.0); + } + for (l, h) in self.linetypes.iter().zip(handles) { + w.record("LTYPE", *h, fixed::LTYPE_TABLE, "AcDbLinetypeTableRecord"); + w.s(2, l.name.as_str()); + w.i(70, 0); + w.s(3, l.description.as_str()); + w.i(72, 65); + w.i(73, n(l.pattern.len())); + w.f(40, l.pattern.iter().map(|e| e.length.abs()).sum()); + for e in &l.pattern { + w.f(49, e.length); + if !w.v.handles() { + continue; + } + let complex = e.text.is_some() || e.shape.is_some(); + w.i( + 74, + i64::from(e.absolute && complex) + | if e.text.is_some() { + 2 + } else if e.shape.is_some() { + 4 + } else { + 0 + }, + ); + if complex { + w.i(75, i64::from(e.shape.unwrap_or(0))); + w.h(340, e.style.unwrap_or(0)); + w.f(46, e.scale); + w.f(50, e.rotation); + w.f(44, e.offset[0]); + w.f(45, e.offset[1]); + if let Some(t) = &e.text { + w.s(9, t.as_str()); + } + } + } + } + w.end_table(); + } + + fn layer_table(&self, w: &mut W, handles: &[u64]) { + let zero = Layer { + handle: None, + name: "0".into(), + color: Color::Index(7), + on: true, + frozen: false, + vp_freeze_new: false, + locked: false, + plot: true, + linetype: "Continuous".into(), + lineweight: Lineweight::Default, + plot_style: None, + transparency: Transparency::ByLayer, + description: None, + }; + w.table("LAYER", fixed::LAYER_TABLE, 1 + self.layers.len()); + for (l, h) in std::iter::once((&zero, fixed::LAYER_0)).chain(self.layers.iter().zip(handles.iter().copied())) { + w.record("LAYER", h, fixed::LAYER_TABLE, "AcDbLayerTableRecord"); + w.s(2, l.name.as_str()); + w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 }); + let (index, rgb) = match l.color { + Color::Index(i) => (i.max(1), None), + Color::Rgb(rgb) => (aci::nearest(rgb, 1..=255), Some(rgb)), + Color::ByLayer | Color::ByBlock => (7, None), + }; + // A negative colour switches the layer off. + w.i(62, if l.on { i64::from(index) } else { -i64::from(index) }); + if let Some(rgb) = rgb.filter(|_| w.v.true_color()) { + w.i(420, i64::from(u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]]))); + } + w.s(6, l.linetype.as_str()); + if w.v.r2000() { + w.i(290, i64::from(l.plot)); + w.i(370, l.lineweight.to_dxf()); + w.h(390, l.plot_style.unwrap_or(fixed::PLOT_NORMAL)); + if let Some(d) = &l.description { + w.s(1001, "AcAecLayerStandard"); + w.s(1000, ""); + w.s(1000, d.as_str()); + } + if l.transparency != Transparency::ByLayer && w.v.true_color() { + w.s(1001, "AcCmTransparency"); + w.i(1071, l.transparency.to_dxf()); + } + } + } + w.end_table(); + } + + fn style_table(&self, w: &mut W, handles: &[u64]) { + let standard = TextStyle { + handle: None, + name: "Standard".into(), + font_file: "txt".into(), + big_font: String::new(), + typeface: None, + height: 0.0, + width_factor: 1.0, + oblique: 0.0, + shape_file: false, + vertical: false, + backwards: false, + upside_down: false, + annotative: false, + }; + w.table("STYLE", fixed::STYLE_TABLE, 1 + self.styles.len()); + for (s, h) in std::iter::once((&standard, fixed::STANDARD_STYLE)).chain(self.styles.iter().zip(handles.iter().copied())) { + w.record("STYLE", h, fixed::STYLE_TABLE, "AcDbTextStyleTableRecord"); + w.s(2, s.name.as_str()); + w.i(70, i64::from(s.shape_file) | if s.vertical { 4 } else { 0 }); + w.f(40, s.height); + w.f(41, s.width_factor); + w.f(50, s.oblique); + w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 }); + w.f(42, 2.5); + w.s(3, s.font_file.as_str()); + w.s(4, s.big_font.as_str()); + // From 2000 the typeface too, which is how CAD apps find a TrueType font. + if let Some(face) = s.typeface.as_ref().filter(|_| w.v.r2000()) { + w.s(1001, "ACAD"); + w.s(1000, face.as_str()); + w.i(1071, 0); + } + } + w.end_table(); + } + + fn dim_style_table(&self, w: &mut W, handles: &[u64]) { + w.table("DIMSTYLE", fixed::DIMSTYLE_TABLE, 1 + self.dim_styles.len()); + w.record("DIMSTYLE", fixed::STANDARD_DIMSTYLE, fixed::DIMSTYLE_TABLE, "AcDbDimStyleTableRecord"); + w.s(2, "Standard"); + w.i(70, 0); + for (d, h) in self.dim_styles.iter().zip(handles) { + w.record("DIMSTYLE", *h, fixed::DIMSTYLE_TABLE, "AcDbDimStyleTableRecord"); + w.s(2, d.name.as_str()); + // The flags come first: a later 70 is DIMTFILLCLR. + w.i(70, d.flags); + w.t.extend(d.vars.iter().map(|(_, t)| (*t).clone())); + } + w.end_table(); + } + + fn objects(&self, w: &mut W) { + let v = self.format; + w.section("OBJECTS"); + let dict = |w: &mut W, h: u64, owner: u64, entries: &[(&str, u64)]| { + w.object("DICTIONARY", h, owner, "AcDbDictionary"); + w.i(281, 1); + for (name, e) in entries { + w.s(3, *name); + w.h(350, *e); + } + }; + let mut root = vec![("ACAD_GROUP", fixed::GROUPS)]; + if v.r2000() { + root.extend([("ACAD_LAYOUT", fixed::LAYOUTS), ("ACAD_PLOTSTYLENAME", fixed::PLOT_STYLES)]); + } + root.extend(self.root_entries.iter().map(|(n, h)| (n.as_str(), *h))); + dict(w, fixed::ROOT, 0, &root); + dict(w, fixed::GROUPS, fixed::ROOT, &[]); + if v.r2000() { + dict(w, fixed::LAYOUTS, fixed::ROOT, &[("Layout1", fixed::PAPER_LAYOUT), ("Model", fixed::MODEL_LAYOUT)]); + w.object("ACDBDICTIONARYWDFLT", fixed::PLOT_STYLES, fixed::ROOT, "AcDbDictionary"); + w.i(281, 1); + w.s(3, "Normal"); + w.h(350, fixed::PLOT_NORMAL); + w.s(100, "AcDbDictionaryWithDefault"); + w.h(340, fixed::PLOT_NORMAL); + // A placeholder has no data and no subclass marker of its own. + w.s(0, "ACDBPLACEHOLDER"); + w.h(5, fixed::PLOT_NORMAL); + w.h(330, fixed::PLOT_STYLES); + self.layout(w, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD); + self.layout(w, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD); + } + w.t.extend(self.raw_objects.iter().cloned()); + w.end_section(); + } + + /// A layout (R2000 and later): its plot settings, then the layout itself. + fn layout(&self, w: &mut W, h: u64, name: &str, tab: i64, record: u64) { + let [x0, y0, x1, y1] = self.extents; + let [lx0, ly0, lx1, ly1] = self.limits; + w.object("LAYOUT", h, fixed::LAYOUTS, "AcDbPlotSettings"); + w.s(1, ""); + w.s(2, "none_device"); + w.s(4, ""); + w.s(6, ""); + for code in 40..=49 { + w.f(code, 0.0); + } + w.f(140, 0.0); + w.f(141, 0.0); + w.f(142, 1.0); + w.f(143, 1.0); + // Plot flags; 1024 marks the model space layout. + w.i(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 }); + w.i(72, 1); + w.i(73, 0); + w.i(74, 5); + w.s(7, ""); + w.i(75, 16); + if self.format.true_color() { + // Shade plot mode (as displayed), resolution level (normal) and custom DPI. + w.i(76, 0); + w.i(77, 2); + w.i(78, 300); + } + w.f(147, 1.0); + // The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158. + w.f(148, 0.0); + w.f(149, 0.0); + w.s(100, "AcDbLayout"); + w.s(1, name); + w.i(70, 1); + w.i(71, tab); + w.xy(10, [lx0, ly0]); + w.xy(11, [lx1, ly1]); + w.p(12, [0.0; 3]); + w.p(14, [x0, y0, 0.0]); + w.p(15, [x1, y1, 0.0]); + w.f(146, 0.0); + w.p(13, [0.0; 3]); + w.p(16, [1.0, 0.0, 0.0]); + w.p(17, [0.0, 1.0, 0.0]); + w.i(76, 1); + w.h(330, record); + } +} + +fn block_begin(w: &mut W, name: &str, base: Point, description: &str, h: u64, record: u64) { + w.object("BLOCK", h, record, "AcDbEntity"); + w.s(8, "0"); + w.sub("AcDbBlockBegin"); + w.s(2, name); + w.i(70, 0); + w.p(10, base); + w.s(3, name); + w.s(1, ""); + if !description.is_empty() && w.v.handles() { + w.s(4, description); + } +} + +fn block_end(w: &mut W, h: u64, record: u64) { + w.object("ENDBLK", h, record, "AcDbEntity"); + w.s(8, "0"); + w.sub("AcDbBlockEnd"); +} diff --git a/crates/dxf/tests/model_write.rs b/crates/dxf/tests/model_write.rs new file mode 100644 index 00000000..5bbd1828 --- /dev/null +++ b/crates/dxf/tests/model_write.rs @@ -0,0 +1,606 @@ +//! Writing the model: what `Drawing::tags` writes reads back as what was given. + +use std::collections::HashSet; + +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, Image, Insert, + Layer, Leader, Linetype, Lineweight, LwPolyline, LwVertex, PatternLine, Polyline, Spline, Text, TextStyle, Transparency, Vertex, Viewport, + records, +}; +use craft_dxf::{Tag, parse, write_ascii_as}; + +fn common(layer: &str) -> Common { + Common { + handle: None, + owner: None, + layer: layer.into(), + linetype: None, + color: Color::ByLayer, + lineweight: Lineweight::ByLayer, + ltscale: 1.0, + visible: true, + paper_space: false, + transparency: Transparency::ByLayer, + thickness: 0.0, + extrusion: [0.0, 0.0, 1.0], + plot_style: None, + } +} + +fn text(value: &str) -> Text { + Text { + insert: [1.0, 2.0, 0.0], + align: Some([3.0, 2.0, 0.0]), + height: 2.5, + value: value.into(), + rotation: 30.0, + width_factor: 0.8, + oblique: 15.0, + style: "Notes".into(), + generation: 2, + halign: 1, + valign: 2, + } +} + +fn mtext(columns: Option) -> MText { + MText { + insert: [1.0, 2.0, 0.0], + height: 2.5, + width: 40.0, + attach: 5, + drawing_direction: 1, + direction: None, + rotation: 0.0, + style: "Notes".into(), + contents: format!("tab\there\nnext ^ line {}", "long ".repeat(120)), + line_spacing: 1.2, + line_spacing_exact: true, + background: Some((1, Color::Index(4), 1.25)), + columns, + } +} + +fn hatch(fit: bool) -> Hatch { + let spline = HatchEdge::Spline { + degree: 1, + rational: true, + periodic: false, + knots: vec![0.0, 0.0, 1.0, 1.0], + control: vec![[0.0, 3.0], [0.0, 0.0]], + weights: vec![1.0, 2.0], + fit: if fit { vec![[0.0, 3.0], [0.0, 0.0]] } else { Vec::new() }, + start_tangent: fit.then_some([0.0, -1.0]), + end_tangent: None, + }; + let edges = vec![ + HatchEdge::Line { start: [0.0, 0.0], end: [4.0, 0.0] }, + HatchEdge::Arc { center: [4.0, 1.0], radius: 1.0, start_angle: 270.0, end_angle: 360.0, ccw: true }, + HatchEdge::Ellipse { center: [2.0, 2.0], major: [2.0, 0.0], ratio: 0.5, start_angle: 0.0, end_angle: 180.0, ccw: false }, + spline, + ]; + Hatch { + pattern: "ANSI31".into(), + solid: false, + associative: true, + style: 1, + pattern_type: 1, + angle: 45.0, + scale: 2.0, + double: false, + elevation: 2.5, + loops: vec![ + HatchLoop { flags: 1, path: HatchPath::Edges(edges), sources: vec![0x91, 0x92] }, + HatchLoop { + flags: 2, + path: HatchPath::Polyline { closed: true, vertices: vec![[1.0, 1.0, 0.5], [2.0, 1.0, 0.0], [2.0, 2.0, 0.0]] }, + sources: vec![], + }, + ], + pattern_lines: vec![ + PatternLine { angle: 45.0, base: [0.0, 0.0], offset: [-1.4, 1.4], dashes: vec![] }, + PatternLine { angle: 135.0, base: [1.0, 2.0], offset: [1.0, 1.0], dashes: vec![3.0, -1.5] }, + ], + seeds: vec![[1.0, 1.0]], + gradient: None, + } +} + +/// One of every kind the writer has, with values that are not the defaults. `full` adds what +/// only the newest versions hold. +fn sample(full: bool) -> Vec { + let e = |kind: EntityKind| Entity { common: common("Walls"), kind }; + let p = |x: f64| [x, x + 1.0, x + 2.0]; + let polyline = + |flags: i64, vertices: Vec| Polyline { flags, elevation: 0.0, start_width: 0.0, end_width: 0.0, m: 0, n: 0, smooth: 0, vertices }; + let vertex = |x: f64, flags: i64, face: [i64; 4]| Vertex { location: [x, 1.0, 0.0], bulge: 0.0, start_width: 0.0, end_width: 0.0, flags, face }; + let attrib = Attrib { + tag: "A".into(), + text: text("one"), + prompt: String::new(), + flags: 1, + lock_position: true, + multiline: full.then(|| MText { background: None, ..mtext(None) }), + }; + let attdef = Attrib { prompt: "Enter\ta".into(), flags: 12, multiline: None, ..attrib.clone() }; + let dim = |flags: i64| Dimension { + flags, + block: Some("*D1".into()), + style: "ISO-25".into(), + defpt: p(1.0), + text_mid: p(2.0), + insertion: [0.0; 3], + p13: [0.0; 3], + p14: [0.0; 3], + p15: [0.0; 3], + p16: [0.0; 3], + text: "<> ^ m".into(), + measurement: Some(12.5), + rotation: 0.0, + oblique: 0.0, + text_rotation: 45.0, + horizontal: 10.0, + leader_length: 0.0, + }; + let image = Image { + insert: p(1.0), + u: [0.5, 0.0, 0.0], + v: [0.0, 0.5, 0.0], + size: [640.0, 480.0], + image_def: Some(0x77), + display: 7, + clipping: true, + brightness: 60, + contrast: 40, + fade: 10, + clip_type: 2, + clip: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], + clip_inverted: full, + }; + let mut styled = e(EntityKind::Line { start: p(1.0), end: p(4.0) }); + styled.common = Common { + handle: Some(0x500), + linetype: Some("DASHED".into()), + color: Color::Rgb([255, 127, 0]), + lineweight: Lineweight::Hundredths(50), + ltscale: 2.5, + visible: false, + paper_space: true, + transparency: Transparency::Alpha(128), + thickness: 1.5, + extrusion: [0.0, 0.0, -1.0], + plot_style: Some(fixed::PLOT_NORMAL), + ..common("Walls") + }; + let columns = MTextColumns { + dynamic: true, + count: 3, + width: 30.0, + gutter: 5.0, + height: 40.0, + auto_height: false, + heights: vec![10.0, 40.0, 20.0], + flow_reversed: true, + }; + let fixed_columns = + MTextColumns { dynamic: false, count: 2, width: 30.0, gutter: 5.0, height: 80.0, auto_height: false, heights: vec![], flow_reversed: false }; + let gradient = + Gradient { name: "LINEAR".into(), angle: 0.5, shift: 1.0, single: false, tint: 0.25, colors: vec![Color::Rgb([0, 0, 255]), Color::Index(2)] }; + vec![ + styled, + e(EntityKind::Point { location: p(1.0), angle: 30.0 }), + e(EntityKind::Circle { center: p(1.0), radius: 3.0 }), + e(EntityKind::Arc { center: p(1.0), radius: 3.0, start_angle: 10.0, end_angle: 200.0 }), + e(EntityKind::Ellipse { center: p(1.0), major: [2.0, 0.0, 0.0], ratio: 0.5, start: 0.25, end: 3.0 }), + e(EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + LwVertex { x: 0.0, y: 0.0, bulge: 1.0, start_width: 0.0, end_width: 0.0 }, + LwVertex { x: 4.0, y: 0.0, bulge: 0.0, start_width: 0.1, end_width: 0.2 }, + LwVertex { x: 4.0, y: 3.0, ..LwVertex::default() }, + ], + closed: true, + plinegen: true, + const_width: 0.5, + elevation: 2.0, + })), + e(EntityKind::Polyline(Polyline { + start_width: 0.5, + end_width: 0.25, + smooth: 6, + ..polyline(1 | 4, vec![Vertex { bulge: 0.5, start_width: 0.1, end_width: 0.2, ..vertex(0.0, 16, [0; 4]) }, vertex(1.0, 0, [0; 4])]) + })), + e(EntityKind::Polyline(polyline(8, vec![vertex(0.0, 32, [0; 4]), vertex(1.0, 32, [0; 4])]))), + e(EntityKind::Polyline(Polyline { + m: 3, + n: 1, + ..polyline( + 64, + vec![ + vertex(0.0, 192, [0; 4]), + vertex(1.0, 192, [0; 4]), + vertex(2.0, 192, [0; 4]), + Vertex { location: [0.0; 3], ..vertex(0.0, 128, [1, 2, -3, 0]) }, + ], + ) + })), + e(EntityKind::Spline(Spline { + degree: 2, + flags: 8, + knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + control: vec![p(0.0), p(1.0), p(2.0)], + weights: vec![1.0, 2.0, 1.0], + fit: vec![p(0.0), p(2.0)], + start_tangent: Some([1.0, 0.0, 0.0]), + end_tangent: Some([0.0, 1.0, 0.0]), + fit_tolerance: 0.01, + })), + 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"))), + e(EntityKind::MText(mtext(None))), + e(EntityKind::MText(MText { direction: Some([0.0, 1.0, 0.0]), background: None, ..mtext(Some(columns)) })), + e(EntityKind::MText(MText { background: None, ..mtext(Some(fixed_columns)) })), + e(EntityKind::AttDef(attdef)), + e(EntityKind::Insert(Insert { + block: "DOOR".into(), + insert: p(1.0), + scale: [2.0, 1.0, -1.0], + rotation: 45.0, + columns: 3, + rows: 2, + column_spacing: 5.0, + row_spacing: 6.0, + attribs: vec![(Common { color: Color::Index(2), handle: Some(0x501), ..common("Tags") }, attrib)], + })), + e(EntityKind::Insert(Insert { + block: "DOOR".into(), + insert: p(1.0), + scale: [1.0; 3], + rotation: 0.0, + columns: 1, + rows: 1, + column_spacing: 0.0, + row_spacing: 0.0, + attribs: vec![], + })), + e(EntityKind::Dimension(Dimension { p13: p(3.0), p14: p(4.0), rotation: 30.0, oblique: 60.0, ..dim(128) })), + e(EntityKind::Dimension(Dimension { p13: p(3.0), p14: p(4.0), ..dim(1) })), + e(EntityKind::Dimension(Dimension { p13: p(3.0), p14: p(4.0), p15: p(5.0), p16: p(6.0), ..dim(2) })), + e(EntityKind::Dimension(Dimension { p15: p(5.0), leader_length: 2.0, ..dim(3) })), + e(EntityKind::Dimension(Dimension { p15: p(5.0), leader_length: 2.0, ..dim(4) })), + e(EntityKind::Dimension(Dimension { p13: p(3.0), p14: p(4.0), p15: p(5.0), ..dim(5) })), + e(EntityKind::Dimension(Dimension { p13: p(3.0), p14: p(4.0), ..dim(6 | 64) })), + e(EntityKind::Leader(Leader { + vertices: vec![p(1.0), [4.0, 5.0, 0.0], p(6.0)], + arrow: false, + spline: true, + style: "Arrows".into(), + annotation_type: 0, + annotation: Some(0x4F), + })), + e(EntityKind::Solid { corners: [p(0.0), p(1.0), p(2.0), p(3.0)] }), + e(EntityKind::Trace { corners: [p(0.0), p(1.0), p(2.0), p(3.0)] }), + e(EntityKind::Face3d { corners: [p(0.0), p(1.0), p(2.0), p(3.0)], hidden_edges: 5 }), + e(EntityKind::Hatch(Hatch { gradient: None, ..hatch(full) })), + e(EntityKind::Hatch(Hatch { + pattern: "SOLID".into(), + solid: true, + angle: 0.0, + scale: 1.0, + pattern_lines: vec![], + gradient: Some(gradient), + ..hatch(full) + })), + e(EntityKind::Viewport(Viewport { + center: p(5.0), + width: 8.0, + height: 6.0, + id: 2, + status: 16384, + view_center: [1.0, 2.0], + view_direction: [0.0, 0.0, 1.0], + view_target: p(9.0), + view_height: 30.0, + twist: 90.0, + clip: Some(0xA1), + frozen_layers: vec![0xB1, 0xB2], + })), + e(EntityKind::Image(image.clone())), + e(EntityKind::Wipeout(Image { image_def: None, ..image })), + e(EntityKind::Tolerance { insert: p(1.0), direction: [0.0, 1.0, 0.0], style: "Tol".into(), text: "{\\Fgdt;j}%%v0.1".into() }), + ] +} + +/// The file as text and back: what a reader of the written file sees. +fn through_text(tags: &[Tag], format: Format) -> Vec { + parse(write_ascii_as(tags, format.utf8()).as_bytes()).expect("the written file parses") +} + +fn read_back(tags: &[Tag]) -> Vec { + Document::read(tags).entities.iter().map(Entity::read).collect() +} + +/// What the writer decides: the handle of an entity that had none, and every owner. +fn settled(mut e: Entity, given: &Entity) -> Entity { + if given.common.handle.is_none() { + assert!(e.common.handle.is_some(), "every entity gets a handle"); + e.common.handle = None; + } + e.common.owner = None; + if let (EntityKind::Insert(i), EntityKind::Insert(g)) = (&mut e.kind, &given.kind) { + for ((c, _), (gc, _)) in i.attribs.iter_mut().zip(&g.attribs) { + c.owner = None; + c.handle = gc.handle.and(c.handle); + } + } + e +} + +#[test] +fn every_entity_reads_back_as_it_was_given() { + for (format, full) in [(Format::R2010, false), (Format::R2018, true)] { + let given = sample(full); + let (tags, skipped) = Drawing { format, entities: given.clone(), ..Drawing::default() }.tags(); + assert_eq!(skipped, 0); + let back = read_back(&through_text(&tags, format)); + assert_eq!(back.len(), given.len()); + for (b, g) in back.into_iter().zip(&given) { + let mut g = g.clone(); + // An R2010 file has no fit data in a hatch's spline edge, and keeps a multiline + // attribute as its one line. + if !full && let EntityKind::Hatch(h) = &mut g.kind { + assert!(matches!(&h.loops[0].path, HatchPath::Edges(e) if matches!(&e[3], HatchEdge::Spline { fit, .. } if fit.is_empty()))); + h.clone_from(&Hatch { + gradient: h.gradient.clone(), + pattern: h.pattern.clone(), + solid: h.solid, + angle: h.angle, + scale: h.scale, + pattern_lines: h.pattern_lines.clone(), + ..hatch(false) + }); + } + assert_eq!(settled(b, &g), g, "{format:?}"); + } + } +} + +#[test] +fn owners_are_the_space_the_entity_is_in() { + let (tags, _) = Drawing { entities: sample(true), ..Drawing::default() }.tags(); + 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[17].kind else { panic!("an insert") }; + assert_eq!(i.attribs[0].0.owner, back[17].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"); +} + +#[test] +fn what_a_version_does_not_have_is_left_out() { + let given = sample(false); + // R12: no handles, owners or subclass markers, and no lightweight polyline. + let (tags, _) = Drawing { format: Format::R12, entities: given.clone(), ..Drawing::default() }.tags(); + assert!(!tags.iter().any(|t| matches!(t.code, 5 | 100 | 105 | 370 | 420 | 440)), "R12 has none of these groups"); + let d = Document::read(&tags); + assert_eq!(d.header.acadver().as_deref(), Some("AC1009")); + assert!(d.objects.is_empty() && d.classes.is_empty() && d.layouts().is_empty()); + let back = read_back(&tags); + let EntityKind::Polyline(p) = &back[5].kind else { panic!("the lightweight polyline as a POLYLINE") }; + assert!(p.closed() && p.flags & 128 != 0); + assert_eq!((p.vertices.len(), p.vertices[0].bulge, p.vertices[1].end_width, p.start_width), (3, 1.0, 0.2, 0.5)); + assert_eq!(p.vertices[2].location, [4.0, 3.0, 2.0], "the elevation is each vertex's z"); + // The colour's nearest index stands in for the true colour. + assert_eq!(back[0].common.color, Color::Index(30)); + assert_eq!((back[0].common.lineweight, back[0].common.transparency), (Lineweight::ByLayer, Transparency::ByLayer)); + // R13 has handles but still no LWPOLYLINE; R14 has it. R2000 has lineweights, R2004 colour. + let kinds = |format| { + let (tags, _) = Drawing { format, entities: given.clone(), ..Drawing::default() }.tags(); + read_back(&tags) + }; + assert!(matches!(kinds(Format::R13)[5].kind, EntityKind::Polyline(_))); + assert!(matches!(kinds(Format::R14)[5].kind, EntityKind::LwPolyline(_))); + assert_eq!(kinds(Format::R14)[0].common.lineweight, Lineweight::ByLayer); + let r2000 = &kinds(Format::R2000)[0].common; + assert_eq!((r2000.lineweight, r2000.color, r2000.transparency), (Lineweight::Hundredths(50), Color::Index(30), Transparency::ByLayer)); + let r2004 = &kinds(Format::R2004)[0].common; + assert_eq!((r2004.color, r2004.transparency), (Color::Rgb([255, 127, 0]), Transparency::Alpha(128))); + for f in Format::ALL { + let (tags, _) = Drawing { format: f, ..Drawing::default() }.tags(); + assert_eq!(Document::read(&through_text(&tags, f)).header.acadver().as_deref(), Some(f.acadver())); + } +} + +fn tables() -> (Vec, Vec, Vec) { + let layer = Layer { + handle: Some(0x200), + name: "Walls".into(), + color: Color::Rgb([255, 0, 255]), + on: false, + frozen: true, + vp_freeze_new: true, + locked: true, + plot: false, + linetype: "GAS".into(), + lineweight: Lineweight::Hundredths(35), + plot_style: Some(fixed::PLOT_NORMAL), + transparency: Transparency::Alpha(127), + description: Some("outer walls".into()), + }; + let plain = Layer { + handle: None, + name: "Plain".into(), + color: Color::Index(3), + on: true, + frozen: false, + vp_freeze_new: false, + locked: false, + plot: true, + linetype: "Continuous".into(), + lineweight: Lineweight::Default, + transparency: Transparency::ByLayer, + description: None, + ..layer.clone() + }; + let text_element = DashElement { + length: -0.2, + absolute: true, + text: Some("GAS".into()), + shape: None, + style: Some(0x201), + scale: 0.1, + rotation: 15.0, + offset: [-0.1, -0.05], + }; + let dash = |length: f64| DashElement { length, scale: 1.0, ..DashElement::default() }; + let gas = Linetype { + handle: Some(0x202), + name: "GAS".into(), + description: "Gas line".into(), + length: 0.95, + pattern: vec![dash(0.5), text_element, dash(-0.25)], + }; + let style = TextStyle { + handle: Some(0x201), + name: "Notes".into(), + font_file: "arial.ttf".into(), + big_font: String::new(), + typeface: Some("Arial".into()), + height: 2.5, + width_factor: 0.8, + oblique: 15.0, + shape_file: false, + vertical: true, + backwards: true, + upside_down: true, + annotative: false, + }; + (vec![layer, plain], vec![gas], vec![style]) +} + +#[test] +fn tables_read_back_as_they_were_given() { + let (layers, linetypes, styles) = tables(); + let source = parse(b"0\nDIMSTYLE\n105\n203\n2\nISO-25\n70\n0\n41\n2.5\n278\n44\n69\n2\n70\n5\n").expect("parses"); + let dim_styles = Document { tables: records(&source), ..Document::default() }.dim_styles(); + let drawing = Drawing { + layers: layers.clone(), + linetypes: linetypes.clone(), + styles: styles.clone(), + dim_styles, + insunits: 4, + metric: false, + ..Drawing::default() + }; + let (tags, _) = drawing.tags(); + let tags = through_text(&tags, Format::R2018); + let d = Document::read(&tags); + let back = d.layers(); + assert_eq!(back[0].name, "0"); + assert_eq!(back[1], layers[0]); + assert!(back[2].handle.is_some_and(|h| h >= fixed::FIRST_FREE)); + assert_eq!(Layer { handle: None, ..back[2].clone() }, Layer { plot_style: Some(fixed::PLOT_NORMAL), ..layers[1].clone() }); + let lt = d.linetypes(); + assert_eq!(lt.iter().map(|l| l.name.as_str()).collect::>(), ["ByBlock", "ByLayer", "Continuous", "GAS"]); + assert_eq!(lt[3], linetypes[0]); + let st = d.text_styles(); + assert_eq!((st[0].name.as_str(), st[0].font_file.as_str()), ("Standard", "txt")); + assert_eq!(st[1], styles[0]); + let ds = d.dim_styles(); + assert_eq!(ds.iter().map(|s| s.name.as_str()).collect::>(), ["Standard", "ISO-25"]); + assert_eq!(ds[1].handle, Some(0x203)); + assert_eq!( + ds[1].vars.iter().map(|(n, t)| (*n, t.str())).collect::>(), + [("DIMASZ", "2.5".into()), ("DIMDSEP", "44".into()), ("DIMTFILL", "2".into()), ("DIMTFILLCLR", "5".to_string())] + ); + assert_eq!((d.header.insunits(), d.header.measurement()), (4, Some(0))); + assert_eq!(d.app_ids()[0].name, "ACAD"); + assert_eq!(d.vports()[0].name, "*Active"); +} + +#[test] +fn the_file_around_the_content() { + let line = |layer: &str| Entity { common: common(layer), kind: EntityKind::Line { start: [0.0; 3], end: [1.0; 3] } }; + let door = BlockDef { + name: "DOOR".into(), + base: [1.0, 2.0, 3.0], + description: "A door".into(), + entities: vec![line("0"), Entity { common: common("0"), kind: EntityKind::Other }], + }; + let raw = vec![Tag::s(0, "MULTILEADER"), Tag::s(5, "900"), Tag::s(8, "0")]; + let drawing = Drawing { + blocks: vec![door], + // Handles in the reserved range, and one given twice, are replaced. + entities: vec![ + Entity { common: Common { handle: Some(0xA), ..common("0") }, ..line("0") }, + Entity { common: Common { handle: Some(0x400), ..common("0") }, ..line("0") }, + Entity { common: Common { handle: Some(0x400), ..common("0") }, ..line("0") }, + ], + raw_entities: raw, + extents: [0.0, 0.0, 100.0, 50.0], + header: vec![ + ("$LTSCALE".into(), vec![Tag::f(40, 2.0)]), + ("$INSUNITS".into(), vec![Tag::i(70, 6)]), + ("$ACADVER".into(), vec![Tag::s(1, "nonsense")]), + ("$HANDSEED".into(), vec![Tag::s(5, "1")]), + ], + root_entries: vec![("ACAD_IMAGE_DICT".into(), 0x901)], + raw_objects: vec![Tag::s(0, "DICTIONARY"), Tag::s(5, "901"), Tag::s(330, "A")], + raw_classes: vec![Tag::s(0, "CLASS"), Tag::s(1, "IMAGEDEF")], + ..Drawing::default() + }; + let (tags, skipped) = drawing.tags(); + assert_eq!(skipped, 1, "the entity with no groups to write"); + assert_eq!(tags.last().map(|t| (t.code, t.str())), Some((0, "EOF".to_string()))); + let d = Document::read(&tags); + assert_eq!(d.sections, 6); + assert!(d.skipped.is_empty()); + // Every handle once, and the seed above them all. + let handles: Vec = tags.iter().filter(|t| matches!(t.code, 5 | 105)).filter_map(|t| u64::from_str_radix(&t.str(), 16).ok()).collect(); + let unique: HashSet = handles.iter().copied().collect(); + let seed = d.header.handseed().expect("a handle seed"); + assert_eq!(unique.len(), handles.len()); + assert!(handles.iter().filter(|h| **h != seed).all(|h| *h < seed)); + assert_eq!(d.entities.iter().filter(|e| e.record().handle() == Some(0x400)).count(), 1); + assert!(d.entities.iter().all(|e| e.record().handle() != Some(0xA))); + assert_eq!(d.entities.len(), 4, "the raw record is written after the entities"); + assert_eq!(d.entities[3].kind, "MULTILEADER"); + // The header: the writer's version and seed, the caller's variables over the defaults. + assert_eq!((d.header.acadver().as_deref(), d.header.ltscale(), d.header.insunits()), (Some("AC1032"), 2.0, 6)); + assert_eq!(d.header.vars.iter().filter(|(n, _)| *n == "$INSUNITS" || *n == "$ACADVER" || *n == "$HANDSEED").count(), 3); + assert_eq!(d.header.point("$EXTMAX"), Some([100.0, 50.0, 0.0])); + // Blocks, their records and the layouts that name them. + let names = d.block_names(); + let block = d.block("DOOR").expect("the block"); + assert_eq!((block.base(), block.description().as_deref(), block.entities.len()), ([1.0, 2.0, 3.0], Some("A door"), 1)); + assert_eq!(block.block_record().and_then(|h| names.get(&h).copied()), Some("DOOR")); + assert_eq!(Entity::read(&block.entities[0]).common.owner, block.block_record()); + let mut layouts = d.layouts(); + layouts.sort_by_key(|l| l.tab_order); + let of = |i: usize| (layouts[i].name.as_str(), layouts[i].block_record.and_then(|h| names.get(&h).copied())); + assert_eq!((of(0), of(1)), (("Model", Some("*Model_Space")), ("Layout1", Some("*Paper_Space")))); + let root = d.root_dictionary().expect("the root dictionary"); + assert_eq!((root.get("ACAD_LAYOUT"), root.get("ACAD_IMAGE_DICT")), (Some(fixed::LAYOUTS), Some(0x901))); + assert!(d.objects.iter().any(|r| r.handle() == Some(0x901))); + assert!(d.classes.iter().any(|r| r.groups().s(1).as_deref() == Some("IMAGEDEF"))); + // The file opens at its extents. + let vp = &d.vports()[0]; + assert_eq!((vp.center, vp.height > 50.0), (Some([50.0, 25.0]), true)); +} + +#[test] +fn one_entity_for_a_file_the_caller_writes() { + let insert = sample(true).remove(17); + let mut next = 0x10; + let tags = + entity_tags(&Entity { common: Common { handle: Some(0x5), ..insert.common.clone() }, ..insert.clone() }, Format::R2018, 0x1F, &mut next); + let (recs, strays) = craft_dxf::model::entity_records(&records(&tags)); + assert_eq!((recs.len(), strays), (1, 0)); + let back = Entity::read(&recs[0]); + assert_eq!((back.common.handle, back.common.owner), (Some(0x5), Some(0x1F)), "its own handle, whatever its number"); + assert_eq!(recs[0].seqend.map(|s| s.handle()), Some(Some(0x10))); + assert_eq!(next, 0x11, "the next free handle is past the ones used"); + assert!(entity_tags(&Entity { common: common("0"), kind: EntityKind::Other }, Format::R2018, 0, &mut next).is_empty()); +}