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()); +}