diff --git a/crates/dxf/src/model.rs b/crates/dxf/src/model.rs index adf8f04e..e705741e 100644 --- a/crates/dxf/src/model.rs +++ b/crates/dxf/src/model.rs @@ -20,13 +20,14 @@ pub mod aci; mod document; mod entity; mod hatch; +pub mod objects; mod tables; pub mod text; pub mod write; pub use document::{Block, Dictionary, Document, EntityRecord, Header, Layout, Record, entity_records, records}; pub use entity::{ - Attrib, Common, Dimension, Entity, EntityKind, Image, Insert, Leader, LwPolyline, LwVertex, Polyline, Spline, Text, Vertex, Viewport, + Attrib, Common, Dimension, Entity, EntityKind, Helix, Image, Insert, Leader, LwPolyline, LwVertex, Polyline, Spline, Text, Vertex, Viewport, }; pub use hatch::{Gradient, Hatch, HatchEdge, HatchLoop, HatchPath, PatternLine}; pub use tables::{AppId, BlockRecord, DashElement, DimStyle, Layer, Linetype, TextStyle, Ucs, View, Vport, dim_var_code, dim_var_name}; diff --git a/crates/dxf/src/model/document.rs b/crates/dxf/src/model/document.rs index 5022b441..cd9ea1aa 100644 --- a/crates/dxf/src/model/document.rs +++ b/crates/dxf/src/model/document.rs @@ -446,6 +446,34 @@ impl<'a> Document<'a> { self.objects.iter().find(|r| r.kind == "DICTIONARY" && r.handle() == Some(handle)).map(Dictionary::read) } + /// The OBJECTS records of type `kind` (`GROUP`, `IMAGEDEF`, `SCALE`...), for the readers of + /// [`super::objects`]. + pub fn objects_of<'s>(&'s self, kind: &'s str) -> impl Iterator> { + self.objects.iter().filter(move |r| r.kind == kind) + } + + /// Object handle → the name a dictionary lists it under: how a GROUP, an MLINESTYLE, a + /// TABLESTYLE or an IMAGEDEF gets its name. Where several dictionaries name a handle, the + /// last one read wins. + pub fn dictionary_names(&self) -> HashMap { + let mut names = HashMap::new(); + for r in self.objects.iter().filter(|r| r.kind == "DICTIONARY" || r.kind == "ACDBDICTIONARYWDFLT") { + let mut name = None; + for t in r.tags { + match t.code { + 3 => name = t.str_ref(), + 350 | 360 => { + if let (Some(n), Some(h)) = (name.take(), parse_handle(&t.str())) { + names.insert(h, n); + } + } + _ => {} + } + } + } + names + } + /// The named object dictionary: the first DICTIONARY of the OBJECTS section. pub fn root_dictionary(&self) -> Option { self.objects.iter().find(|r| r.kind == "DICTIONARY").map(Dictionary::read) diff --git a/crates/dxf/src/model/entity.rs b/crates/dxf/src/model/entity.rs index 6b05f274..ae5cf23a 100644 --- a/crates/dxf/src/model/entity.rs +++ b/crates/dxf/src/model/entity.rs @@ -178,6 +178,29 @@ pub struct Spline { pub fit_tolerance: f64, } +/// A HELIX: a spline (the curve as drawn) and the parameters it was made from. +#[derive(Clone, Debug, PartialEq)] +pub struct Helix { + /// The `AcDbSpline` groups. + pub spline: Spline, + /// Group 10: the axis base point. + pub axis_base: Point, + /// Group 11: the start point. + pub start: Point, + /// Group 12: the axis direction. + pub axis: Point, + /// Group 40: the base radius. + pub radius: f64, + /// Group 41. + pub turns: f64, + /// Group 42: the height of one turn. + pub turn_height: f64, + /// Group 290 is not 0: counter-clockwise (the default). + pub ccw: bool, + /// Group 280: what stays fixed when the helix is edited (0 turn height, 1 turns, 2 height). + pub constrain: i64, +} + /// TEXT, and the text of an ATTRIB or ATTDEF. #[derive(Clone, Debug, PartialEq)] pub struct Text { @@ -430,6 +453,7 @@ pub enum EntityKind { LwPolyline(LwPolyline), Polyline(Polyline), Spline(Spline), + Helix(Helix), Ray { base: Point, direction: Point, @@ -634,6 +658,20 @@ impl Entity { "LWPOLYLINE" => EntityKind::LwPolyline(lwpolyline(e.tags, &g)), "POLYLINE" => EntityKind::Polyline(polyline(&g, &e.followers)), "SPLINE" => EntityKind::Spline(spline(&g)), + "HELIX" => { + let h = g.subclass("AcDbHelix"); + EntityKind::Helix(Helix { + spline: spline(&g.subclass("AcDbSpline")), + axis_base: h.point(10), + start: h.point(11), + axis: h.point_opt(12).unwrap_or([0.0, 0.0, 1.0]), + radius: h.f_or(40, 0.0), + turns: h.f_or(41, 1.0), + turn_height: h.f_or(42, 0.0), + ccw: h.i_or(290, 1) != 0, + constrain: h.i_or(280, 2), + }) + } "RAY" => EntityKind::Ray { base: line(&g).0, direction: line(&g).1 }, "XLINE" => EntityKind::XLine { base: line(&g).0, direction: line(&g).1 }, "TEXT" => EntityKind::Text(Text::read(&g)), diff --git a/crates/dxf/src/model/objects.rs b/crates/dxf/src/model/objects.rs new file mode 100644 index 00000000..589f1eee --- /dev/null +++ b/crates/dxf/src/model/objects.rs @@ -0,0 +1,398 @@ +//! The records of the OBJECTS section, typed: page setups, groups, image definitions, draw +//! order, clip filters, dimension associativity, annotation scales and multiline styles. And +//! the dimension style overrides an entity carries in its extended data. +//! +//! Each `read` takes a [`Record`] of [`Document::objects`](super::Document). An object's name is +//! not in its record but in the dictionary that lists it +//! ([`Document::dictionary_names`](super::Document::dictionary_names)). + +use super::document::Record; +use super::tables::dim_var_name; +use super::{Color, Groups, Point, parse_handle, xdata, xdata_list}; +use crate::Tag; + +/// The groups of a record's subclass `marker` and everything after it up to the next marker; +/// the whole record when it has no such marker. +fn own<'a>(r: &Record<'a>, marker: &str) -> &'a [Tag] { + let Some(at) = r.tags.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == marker)) else { return r.tags }; + let rest = r.tags.get(at + 1..).unwrap_or(&[]); + rest.get(..rest.iter().position(|t| t.code == 100).unwrap_or(rest.len())).unwrap_or(rest) +} + +/// The plot settings of a LAYOUT (its page setup), or a PLOTSETTINGS object: the +/// `AcDbPlotSettings` groups. +#[derive(Clone, Debug, PartialEq)] +pub struct PlotSettings { + /// Group 1: the page setup's name. + pub name: String, + /// Group 2: the printer or plotter configuration (`none_device` for none). + pub device: String, + /// Group 4: the paper size's name. + pub paper: String, + /// Group 6: the plot view's name. + pub view: String, + /// Groups 40 to 43: the unprintable margins, left, bottom, right, top, in millimetres. + pub margins: [f64; 4], + /// Groups 44 and 45: the paper's width and height in millimetres. + pub paper_size: [f64; 2], + /// Groups 46 and 47: the plot origin's offset in millimetres. + pub origin: [f64; 2], + /// Groups 48, 49, 140 and 141: the plot window, lower-left x and y, upper-right x and y. + pub window: [f64; 4], + /// Groups 142 and 143: the custom scale, paper units over drawing units. + pub scale: [f64; 2], + /// Group 70: 1 viewport borders, 2 show plot styles, 4 centred, 8 hidden, 16 standard scale, + /// 32 plot with plot styles, 64 scale lineweights, 128 print lineweights, 512 paper space + /// last, 1024 model type, 2048 update paper, 4096 zoom to paper on update. + pub flags: i64, + /// Group 72: 0 inches, 1 millimetres, 2 pixels. + pub paper_units: i64, + /// Group 73: 0 none, 1 90° counter-clockwise, 2 upside down, 3 90° clockwise. + pub rotation: i64, + /// Group 74: 0 the display, 1 extents, 2 limits, 3 the view of group 6, 4 the window, 5 the + /// layout. + pub plot_type: i64, + /// Group 7: the plot style table (`monochrome.ctb`). + pub style_sheet: String, + /// Group 75: the standard scale (0 scaled to fit). + pub standard_scale: i64, + /// Group 147: the scale as one factor. + pub scale_factor: f64, + /// Groups 148 and 149: the paper image origin. + pub image_origin: [f64; 2], +} + +impl PlotSettings { + /// The plot settings of a LAYOUT or PLOTSETTINGS record: the groups of its + /// `AcDbPlotSettings` subclass (a record without the marker is read up to `AcDbLayout`). + pub fn read(r: &Record<'_>) -> PlotSettings { + let g = Groups(own(r, "AcDbPlotSettings")); + let pair = |a: i32, b: i32| [g.f_or(a, 0.0), g.f_or(b, 0.0)]; + PlotSettings { + name: g.s(1).unwrap_or_default(), + device: g.s(2).unwrap_or_default(), + paper: g.s(4).unwrap_or_default(), + view: g.s(6).unwrap_or_default(), + margins: [g.f_or(40, 0.0), g.f_or(41, 0.0), g.f_or(42, 0.0), g.f_or(43, 0.0)], + paper_size: pair(44, 45), + origin: pair(46, 47), + window: [g.f_or(48, 0.0), g.f_or(49, 0.0), g.f_or(140, 0.0), g.f_or(141, 0.0)], + scale: [g.f_or(142, 1.0), g.f_or(143, 1.0)], + flags: g.i_or(70, 0), + paper_units: g.i_or(72, 0), + rotation: g.i_or(73, 0), + plot_type: g.i_or(74, 5), + style_sheet: g.s(7).unwrap_or_default(), + standard_scale: g.i_or(75, 0), + scale_factor: g.f_or(147, 1.0), + image_origin: pair(148, 149), + } + } +} + +/// A GROUP: entities selected together. +#[derive(Clone, Debug, PartialEq)] +pub struct Group { + pub handle: Option, + /// Group 300. + pub description: String, + /// Group 70 is 1: an unnamed group (`*A1`). + pub unnamed: bool, + /// Group 71 is not 0. + pub selectable: bool, + /// Groups 340. + pub members: Vec, +} + +impl Group { + pub fn read(r: &Record<'_>) -> Group { + let g = Groups(own(r, "AcDbGroup")); + Group { + handle: r.handle(), + description: g.s(300).unwrap_or_default(), + unnamed: g.i_or(70, 0) == 1, + selectable: g.i_or(71, 1) != 0, + members: g.0.iter().filter(|t| t.code == 340).filter_map(|t| parse_handle(&t.str())).collect(), + } + } +} + +/// An IMAGEDEF: the file an IMAGE shows. +#[derive(Clone, Debug, PartialEq)] +pub struct ImageDef { + pub handle: Option, + /// Group 1: the file's path. + pub file: String, + /// Groups 10/20: the image's size in pixels. + pub size: [f64; 2], + /// Groups 11/21: one pixel's size in drawing units. + pub pixel_size: [f64; 2], + /// Group 280 is not 0. + pub loaded: bool, + /// Group 281: 0 none, 2 centimetres, 5 inches. + pub units: i64, +} + +impl ImageDef { + pub fn read(r: &Record<'_>) -> ImageDef { + let g = Groups(own(r, "AcDbRasterImageDef")); + ImageDef { + handle: r.handle(), + file: g.s(1).unwrap_or_default(), + size: [g.f_or(10, 0.0), g.f_or(20, 0.0)], + pixel_size: [g.f_or(11, 1.0), g.f_or(21, 1.0)], + loaded: g.i_or(280, 1) != 0, + units: g.i_or(281, 0), + } + } +} + +/// A SORTENTSTABLE: the draw order of a block's entities. +#[derive(Clone, Debug, PartialEq)] +pub struct SortEntsTable { + /// Group 330 after the marker: the BLOCK_RECORD whose entities are ordered. + pub block_record: Option, + /// Each entity (group 331) with the sort handle (group 5) it is drawn by: entities draw in + /// the order of their sort handles, one not listed by its own handle. + pub entries: Vec<(u64, u64)>, +} + +impl SortEntsTable { + /// `None` for a record without the `AcDbSortentsTable` marker. + pub fn read(r: &Record<'_>) -> Option { + let start = r.tags.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == "AcDbSortentsTable"))?; + let tail = r.tags.get(start + 1..)?; + let mut entries = Vec::new(); + let mut entity = None; + for t in tail { + match t.code { + 331 => entity = parse_handle(&t.str()), + 5 => entries.extend(entity.take().zip(parse_handle(&t.str()))), + _ => {} + } + } + Some(SortEntsTable { block_record: Groups(tail).handle(330), entries }) + } +} + +/// A SPATIAL_FILTER: the clip boundary of a block reference (XCLIP). +#[derive(Clone, Debug, PartialEq)] +pub struct SpatialFilter { + pub handle: Option, + /// Groups 10/20: the boundary in the filter's own coordinate system; two points are the + /// corners of a rectangle. + pub boundary: Vec<[f64; 2]>, + /// Groups 210/220/230: the boundary plane's normal. + pub normal: Point, + /// Groups 11/21/31: the boundary plane's origin. + pub origin: Point, + /// Group 71 is not 0. + pub enabled: bool, + /// The front and back clip distances, when groups 72 and 73 switch them on. + pub front: Option, + pub back: Option, + /// The first twelve 40s after them: the inverse of the block reference's transform, a 4x3 + /// matrix by rows. Then twelve more: from block coordinates to the boundary's. + pub inverse_insert: Option<[f64; 12]>, + pub to_boundary: Option<[f64; 12]>, + /// Group 290 is not 0: the clip keeps the outside. + pub inverted: bool, +} + +impl SpatialFilter { + pub fn read(r: &Record<'_>) -> SpatialFilter { + let tags = own(r, "AcDbSpatialFilter"); + let g = Groups(tags); + // 72 and 73 switch the front and back clips on; each distance follows its switch. + let after = |code: i32| tags.iter().position(|t| t.code == code).map(|i| (tags.get(i).is_some_and(|t| t.i64() != 0), i + 1)); + let front = after(72).and_then(|(on, i)| tags.get(i).filter(|t| on && t.code == 40).map(Tag::f64)); + let back = after(73).and_then(|(on, i)| tags.get(i).filter(|t| on && t.code == 41).map(Tag::f64)); + let from = after(73).or(after(72)).map_or(0, |(_, i)| i); + let m: Vec = tags.get(from..).unwrap_or(&[]).iter().filter(|t| t.code == 40).take(24).map(Tag::f64).collect(); + let matrix = |at: usize| m.get(at..at + 12).and_then(|s| <[f64; 12]>::try_from(s).ok()); + SpatialFilter { + handle: r.handle(), + boundary: g.points(10).into_iter().map(|p| [p[0], p[1]]).collect(), + normal: [g.f_or(210, 0.0), g.f_or(220, 0.0), g.f_or(230, 1.0)], + origin: g.point(11), + enabled: g.i_or(71, 1) != 0, + front, + back, + inverse_insert: matrix(0), + to_boundary: matrix(12), + inverted: g.i_or(290, 0) != 0, + } + } +} + +/// One point reference of a DIMASSOC: the geometry a definition point follows. +#[derive(Clone, Debug, PartialEq)] +pub struct OsnapRef { + /// Group 72: the object snap (1 endpoint, 2 midpoint, 3 centre, 4 node, 5 quadrant, 6 + /// intersection, 7 insertion, 8 perpendicular, 9 tangent, 10 nearest, 11 apparent + /// intersection, 12 parallel, 13 start point). + pub snap: i64, + /// Group 331: the entity. + pub entity: Option, + /// Group 73: the kind of its part (1 the whole, 2 an edge, 3 a vertex), and group 91, the + /// part's index. + pub sub_type: i64, + pub sub_index: i64, + /// Group 332: the second entity of an intersection. + pub other: Option, + /// Group 40: the parameter along the geometry, for a nearest snap. + pub near: f64, + /// Groups 10/20/30: the point. + pub point: Point, +} + +/// A DIMASSOC: what the definition points of a dimension are attached to. +#[derive(Clone, Debug, PartialEq)] +pub struct DimAssoc { + pub handle: Option, + /// Group 330 after the marker: the dimension. + pub dimension: Option, + /// Group 90: bit n is set when the nth definition point has a reference (in the order the + /// references follow). + pub flags: i64, + /// Group 70 is not 0: the dimension is in paper space and its geometry in model space. + pub trans_space: bool, + /// Group 71: the rotated dimension type (parallel or perpendicular). + pub rotated_type: i64, + /// The references, each started by `1 AcDbOsnapPointRef`. + pub refs: Vec, +} + +impl DimAssoc { + pub fn read(r: &Record<'_>) -> DimAssoc { + let start = r.tags.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == "AcDbDimAssoc")).unwrap_or(0); + let tail = r.tags.get(start..).unwrap_or(&[]); + let starts: Vec = tail.iter().enumerate().filter(|(_, t)| t.code == 1).map(|(i, _)| i).collect(); + let head = Groups(tail.get(..starts.first().copied().unwrap_or(tail.len())).unwrap_or(tail)); + let refs = starts + .iter() + .enumerate() + .map(|(k, s)| { + let g = Groups(tail.get(s + 1..starts.get(k + 1).copied().unwrap_or(tail.len())).unwrap_or(&[])); + OsnapRef { + snap: g.i_or(72, 0), + entity: g.handle(331), + sub_type: g.i_or(73, 0), + sub_index: g.i_or(91, 0), + other: g.handle(332), + near: g.f_or(40, 0.0), + point: g.point(10), + } + }) + .collect(); + DimAssoc { + handle: r.handle(), + dimension: head.handle(330), + flags: head.i_or(90, 0), + trans_space: head.i_or(70, 0) != 0, + rotated_type: head.i_or(71, 0), + refs, + } + } +} + +/// A SCALE: an annotation scale of the drawing's scale list. +#[derive(Clone, Debug, PartialEq)] +pub struct Scale { + pub handle: Option, + /// Group 300 (`1:50`). + pub name: String, + /// Groups 140 and 141: paper units and drawing units. + pub paper: f64, + pub drawing: f64, + /// Group 290 is not 0: the 1:1 scale. + pub unit: bool, +} + +impl Scale { + /// `None` for a record without a name or one of its numbers. + pub fn read(r: &Record<'_>) -> Option { + let g = Groups(own(r, "AcDbScale")); + Some(Scale { handle: r.handle(), name: g.name(300)?, paper: g.f(140)?, drawing: g.f(141)?, unit: g.i_or(290, 0) != 0 }) + } +} + +/// One line of a multiline style. +#[derive(Clone, Debug, PartialEq)] +pub struct MLineElement { + /// Group 49: the offset from the multiline's path. + pub offset: f64, + /// Group 62 (ByLayer when absent). + pub color: Color, + /// Group 6 (`BYLAYER` when absent), as written. + pub linetype: String, +} + +/// An MLINESTYLE. +#[derive(Clone, Debug, PartialEq)] +pub struct MLineStyle { + pub handle: Option, + /// Group 2. + pub name: String, + /// Group 3. + pub description: String, + /// Group 70: 1 filled, 2 joints shown, 16 start line cap, 32 start inner arcs, 64 start + /// outer arc, 256 end line cap, 512 end inner arcs, 1024 end outer arc. + pub flags: i64, + /// The 62 before the first element: the fill colour. + pub fill: Color, + /// Groups 51 and 52: the start and end cap angles, in degrees. + pub start_angle: f64, + pub end_angle: f64, + pub elements: Vec, +} + +impl MLineStyle { + /// `None` for a style without a name. + pub fn read(r: &Record<'_>) -> Option { + let body = own(r, "AcDbMlineStyle"); + let g = Groups(body); + let mut st = MLineStyle { + handle: r.handle(), + name: g.name(2)?, + description: g.s(3).unwrap_or_default(), + flags: g.i_or(70, 0), + fill: Color::ByLayer, + start_angle: g.f_or(51, 90.0), + end_angle: g.f_or(52, 90.0), + elements: Vec::new(), + }; + for t in body { + match (t.code, st.elements.last_mut()) { + (49, _) => st.elements.push(MLineElement { offset: t.f64(), color: Color::ByLayer, linetype: "BYLAYER".into() }), + (62, Some(e)) => e.color = Color::from_index(t.i64()), + (62, None) => st.fill = Color::from_index(t.i64()), + (6, Some(e)) => e.linetype = t.str(), + _ => {} + } + } + Some(st) + } +} + +/// The dimension variables an entity overrides: the `DSTYLE` list of its `ACAD` extended data, +/// pairs of a `1070` group code (the variable's DIMSTYLE code) and the value. Each is the +/// variable's name and the group holding the value, as [`super::DimStyle::vars`] has them +/// (a handle value is a `1005`). +pub fn dim_overrides(tags: &[Tag]) -> Vec<(&'static str, &Tag)> { + let list = xdata_list(xdata(tags, "ACAD"), "DSTYLE"); + let mut out = Vec::new(); + let mut i = 0; + while let (Some(code), Some(value)) = (list.get(i), list.get(i + 1)) { + // A value is never a 1070 naming a variable twice in a row: realign on a stray group. + if code.code != 1070 { + i += 1; + continue; + } + if let Some(name) = i32::try_from(code.i64()).ok().and_then(dim_var_name) { + out.push((name, value)); + } + i += 2; + } + out +} diff --git a/crates/dxf/src/model/write.rs b/crates/dxf/src/model/write.rs index 17b79fe1..4a1b11ad 100644 --- a/crates/dxf/src/model/write.rs +++ b/crates/dxf/src/model/write.rs @@ -794,6 +794,21 @@ impl W { EntityKind::LwPolyline(p) => self.lwpolyline(p, c), EntityKind::Polyline(p) => self.polyline_head(p, c), EntityKind::Spline(s) => self.spline(s), + EntityKind::Helix(h) => { + self.spline(&h.spline); + self.sub("AcDbHelix"); + // The release numbers the format expects. + self.i(90, 29); + self.i(91, 63); + self.p(10, h.axis_base); + self.p(11, h.start); + self.p(12, h.axis); + self.f(40, h.radius); + self.f(41, h.turns); + self.f(42, h.turn_height); + self.i(290, i64::from(h.ccw)); + self.i(280, h.constrain); + } EntityKind::Ray { base, direction } | EntityKind::XLine { base, direction } => { self.sub(if matches!(kind, EntityKind::Ray { .. }) { "AcDbRay" } else { "AcDbXline" }); self.p(10, *base); @@ -868,6 +883,7 @@ fn kind_name(kind: &EntityKind) -> Option<&'static str> { EntityKind::LwPolyline(_) => "LWPOLYLINE", EntityKind::Polyline(_) => "POLYLINE", EntityKind::Spline(_) => "SPLINE", + EntityKind::Helix(_) => "HELIX", EntityKind::Ray { .. } => "RAY", EntityKind::XLine { .. } => "XLINE", EntityKind::Text(_) => "TEXT", diff --git a/crates/dxf/tests/model_objects.rs b/crates/dxf/tests/model_objects.rs new file mode 100644 index 00000000..976805bf --- /dev/null +++ b/crates/dxf/tests/model_objects.rs @@ -0,0 +1,135 @@ +//! The typed OBJECTS records and dimension style overrides. + +use craft_dxf::model::objects::{DimAssoc, Group, ImageDef, MLineStyle, PlotSettings, Scale, SortEntsTable, SpatialFilter, dim_overrides}; +use craft_dxf::model::{Color, Document, Entity, EntityKind}; +use craft_dxf::{Tag, parse}; + +fn tags(text: &str) -> Vec { + let lines: Vec<&str> = text.split(['\n', '|']).map(str::trim).filter(|l| !l.is_empty()).map(|l| if l == "~" { "" } else { l }).collect(); + parse((lines.join("\n") + "\n").as_bytes()).expect("the test drawing parses") +} + +const OBJECTS: &str = " +0|SECTION|2|OBJECTS +0|DICTIONARY|5|C|3|ACAD_GROUP|350|D|3|ACAD_MLINESTYLE|350|17 +0|DICTIONARY|5|D|3|Doors|350|40 +0|DICTIONARY|5|17|3|Wall|350|41 +0|GROUP|5|40|330|D|100|AcDbGroup|300|All doors|70|0|71|0|340|A1|340|A2 +0|MLINESTYLE|5|41|100|AcDbMlineStyle|2|WALL|70|273|3|A wall|62|5|51|45.0|52|90.0|71|2|49|0.5|62|1|6|DASHED|49|-0.5|62|256|6|BYLAYER +0|IMAGEDEF|5|42|100|AcDbRasterImageDef|90|0|1|plans/site.png|10|640.0|20|480.0|11|0.01|21|0.01|280|1|281|5 +0|SORTENTSTABLE|5|43|330|44|100|AcDbSortentsTable|330|1F|331|A1|5|B|331|A2|5|A|331|zz|5|9|5|8 +0|SPATIAL_FILTER|5|45|100|AcDbFilter|100|AcDbSpatialFilter|70|2|10|0.0|20|0.0|10|4.0|20|3.0|210|0.0|220|0.0|230|1.0|11|1.0|21|2.0|31|3.0|71|1|72|1|40|7.5|73|0 +40|1.0|40|0.0|40|0.0|40|-5.0|40|0.0|40|1.0|40|0.0|40|-6.0|40|0.0|40|0.0|40|1.0|40|0.0 +40|2.0|40|0.0|40|0.0|40|0.0|40|0.0|40|2.0|40|0.0|40|0.0|40|0.0|40|0.0|40|2.0|40|0.0 +0|DIMASSOC|5|46|330|77|100|AcDbDimAssoc|330|D1|90|3|70|1|71|0 +1|AcDbOsnapPointRef|72|1|331|A1|73|2|91|4|40|0.0|10|1.0|20|2.0|30|0.0 +1|AcDbOsnapPointRef|72|6|331|A2|73|0|91|0|40|0.5|10|3.0|20|4.0|30|0.0|332|A3 +0|SCALE|5|47|100|AcDbScale|70|0|300|1:50|140|1.0|141|50.0|290|0 +0|SCALE|5|48|100|AcDbScale|300|broken|140|1.0 +0|LAYOUT|5|49|100|AcDbPlotSettings|1|Final|2|printer.pc3|4|ISO_A3_(420.00_x_297.00_MM)|6|~|40|7.5|41|20.0|42|7.5|43|20.0|44|420.0|45|297.0|46|1.0|47|2.0|48|0.0|49|0.0|140|100.0|141|50.0|142|1.0|143|50.0|70|688|72|1|73|1|74|4|7|monochrome.ctb|75|0|147|0.02|148|3.0|149|4.0|100|AcDbLayout|1|Sheet|70|1|71|1|10|9.0 +0|ENDSEC|0|EOF +"; + +#[test] +fn objects_typed() { + let t = tags(OBJECTS); + let d = Document::read(&t); + let names = d.dictionary_names(); + assert_eq!((names.get(&0x40).copied(), names.get(&0x41).copied(), names.get(&0xD).copied()), (Some("Doors"), Some("Wall"), Some("ACAD_GROUP"))); + let one = |kind: &'static str| *d.objects_of(kind).next().expect("the record"); + + let g = Group::read(&one("GROUP")); + assert_eq!( + (g.handle, g.description.as_str(), g.unnamed, g.selectable, g.members.as_slice()), + (Some(0x40), "All doors", false, false, [0xA1, 0xA2].as_slice()) + ); + + let m = MLineStyle::read(&one("MLINESTYLE")).expect("a style"); + assert_eq!( + (m.name.as_str(), m.description.as_str(), m.flags, m.fill, m.start_angle, m.end_angle), + ("WALL", "A wall", 273, Color::Index(5), 45.0, 90.0) + ); + assert_eq!(m.elements.len(), 2); + assert_eq!((m.elements[0].offset, m.elements[0].color, m.elements[0].linetype.as_str()), (0.5, Color::Index(1), "DASHED")); + assert_eq!((m.elements[1].offset, m.elements[1].color), (-0.5, Color::ByLayer)); + + let i = ImageDef::read(&one("IMAGEDEF")); + assert_eq!( + (i.handle, i.file.as_str(), i.size, i.pixel_size, i.loaded, i.units), + (Some(0x42), "plans/site.png", [640.0, 480.0], [0.01, 0.01], true, 5) + ); + + let s = SortEntsTable::read(&one("SORTENTSTABLE")).expect("a table"); + // The block is the 330 after the marker, not the owning dictionary; a pair needs both handles. + assert_eq!((s.block_record, s.entries.as_slice()), (Some(0x1F), [(0xA1, 0xB), (0xA2, 0xA)].as_slice())); + + let f = SpatialFilter::read(&one("SPATIAL_FILTER")); + assert_eq!( + (f.boundary.as_slice(), f.normal, f.origin, f.enabled, f.inverted), + ([[0.0, 0.0], [4.0, 3.0]].as_slice(), [0.0, 0.0, 1.0], [1.0, 2.0, 3.0], true, false) + ); + // The front clip distance is the 40 after its switch, not the first matrix value. + assert_eq!((f.front, f.back), (Some(7.5), None)); + assert_eq!(f.inverse_insert.map(|m| (m[0], m[3], m[7])), Some((1.0, -5.0, -6.0))); + assert_eq!(f.to_boundary.map(|m| (m[0], m[5], m[10])), Some((2.0, 2.0, 2.0))); + + let a = DimAssoc::read(&one("DIMASSOC")); + assert_eq!((a.handle, a.dimension, a.flags, a.trans_space, a.refs.len()), (Some(0x46), Some(0xD1), 3, true, 2)); + assert_eq!((a.refs[0].snap, a.refs[0].entity, a.refs[0].sub_type, a.refs[0].sub_index, a.refs[0].point), (1, Some(0xA1), 2, 4, [1.0, 2.0, 0.0])); + assert_eq!((a.refs[1].snap, a.refs[1].other, a.refs[1].near), (6, Some(0xA3), 0.5)); + + let scales: Vec = d.objects_of("SCALE").filter_map(Scale::read).collect(); + assert_eq!(scales.len(), 1, "a scale without both numbers is none"); + assert_eq!((scales[0].name.as_str(), scales[0].paper, scales[0].drawing, scales[0].unit), ("1:50", 1.0, 50.0, false)); + + let p = PlotSettings::read(&one("LAYOUT")); + // The plot settings' own 1, 70 and 10, not the layout's after them. + assert_eq!((p.name.as_str(), p.device.as_str(), p.paper.as_str(), p.view.as_str()), ("Final", "printer.pc3", "ISO_A3_(420.00_x_297.00_MM)", "")); + assert_eq!((p.margins, p.paper_size, p.origin, p.window), ([7.5, 20.0, 7.5, 20.0], [420.0, 297.0], [1.0, 2.0], [0.0, 0.0, 100.0, 50.0])); + assert_eq!((p.scale, p.flags, p.paper_units, p.rotation, p.plot_type), ([1.0, 50.0], 688, 1, 1, 4)); + assert_eq!((p.style_sheet.as_str(), p.standard_scale, p.scale_factor, p.image_origin), ("monochrome.ctb", 0, 0.02, [3.0, 4.0])); +} + +#[test] +fn damaged_objects_read_as_what_they_hold() { + let t = tags( + "0|SECTION|2|OBJECTS|0|GROUP|0|MLINESTYLE|0|IMAGEDEF|0|SORTENTSTABLE|0|SPATIAL_FILTER|72|1|0|DIMASSOC|1|x|0|SCALE|0|LAYOUT|0|DICTIONARY|3|a|350|zz|0|ENDSEC|0|EOF", + ); + let d = Document::read(&t); + let one = |kind: &'static str| *d.objects_of(kind).next().expect("the record"); + assert!(Group::read(&one("GROUP")).members.is_empty()); + assert!(MLineStyle::read(&one("MLINESTYLE")).is_none()); + assert_eq!(ImageDef::read(&one("IMAGEDEF")).file, ""); + assert!(SortEntsTable::read(&one("SORTENTSTABLE")).is_none()); + let f = SpatialFilter::read(&one("SPATIAL_FILTER")); + assert_eq!((f.front, f.inverse_insert, f.to_boundary, f.boundary.len()), (None, None, None, 0)); + assert_eq!(DimAssoc::read(&one("DIMASSOC")).refs.len(), 1); + assert!(Scale::read(&one("SCALE")).is_none()); + assert_eq!(PlotSettings::read(&one("LAYOUT")).plot_type, 5); + assert!(d.dictionary_names().is_empty()); +} + +#[test] +fn dimension_style_overrides() { + let t = tags( + "0|DIMENSION|70|1|1001|OTHER|1000|DSTYLE|1002|{|1070|41|1040|9.0|1002|}|1001|ACAD|1000|DSTYLE|1002|{|1070|41|1040|2.5|1070|9999|1070|1|1040|7.0|1070|340|1005|2F|1070|147|1002|}|1000|after", + ); + let o = dim_overrides(&t[1..]); + // The ACAD list only; a code that names no variable is passed over with its value, and a + // stray group realigns on the next 1070. + assert_eq!(o.iter().map(|(n, v)| (*n, v.str())).collect::>(), [("DIMASZ", "2.5".to_string()), ("DIMTXSTY", "2F".to_string())]); + assert!(dim_overrides(&tags("0|DIMENSION|70|1")).is_empty()); +} + +#[test] +fn helix() { + let t = tags( + "0|SECTION|2|ENTITIES|0|HELIX|100|AcDbEntity|8|0|100|AcDbSpline|70|0|71|3|40|0.0|40|1.0|10|1.0|20|0.0|30|0.0|10|0.0|20|1.0|30|4.0|100|AcDbHelix|90|29|91|63|10|0.5|20|0.5|30|0.0|11|1.5|21|0.5|31|0.0|12|0.0|22|0.0|32|1.0|40|1.0|41|3.0|42|2.0|290|0|280|1|0|ENDSEC|0|EOF", + ); + let d = Document::read(&t); + let EntityKind::Helix(h) = Entity::read(&d.entities[0]).kind else { panic!("a helix") }; + // The spline's 10s and 40s and the helix's own are kept apart by their subclasses. + assert_eq!((h.spline.control.len(), h.spline.knots.len(), h.spline.degree), (2, 2, 3)); + assert_eq!((h.axis_base, h.start, h.axis), ([0.5, 0.5, 0.0], [1.5, 0.5, 0.0], [0.0, 0.0, 1.0])); + assert_eq!((h.radius, h.turns, h.turn_height, h.ccw, h.constrain), (1.0, 3.0, 2.0, false, 1)); +} diff --git a/crates/dxf/tests/model_write.rs b/crates/dxf/tests/model_write.rs index 5bbd1828..d20b02dc 100644 --- a/crates/dxf/tests/model_write.rs +++ b/crates/dxf/tests/model_write.rs @@ -5,9 +5,9 @@ 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, + Attrib, Color, Common, DashElement, Dimension, Document, Entity, EntityKind, Gradient, Hatch, HatchEdge, HatchLoop, HatchPath, Helix, Image, + Insert, Layer, Leader, Linetype, Lineweight, LwPolyline, LwVertex, PatternLine, Polyline, Spline, Text, TextStyle, Transparency, Vertex, + Viewport, records, }; use craft_dxf::{Tag, parse, write_ascii_as}; @@ -236,6 +236,27 @@ fn sample(full: bool) -> Vec { end_tangent: Some([0.0, 1.0, 0.0]), fit_tolerance: 0.01, })), + e(EntityKind::Helix(Helix { + spline: Spline { + degree: 3, + flags: 0, + knots: vec![0.0, 1.0], + control: vec![p(0.0), p(1.0)], + weights: vec![], + fit: vec![], + start_tangent: None, + end_tangent: None, + fit_tolerance: 0.0, + }, + axis_base: p(1.0), + start: p(2.0), + axis: [0.0, 0.0, 1.0], + radius: 2.0, + turns: 3.0, + turn_height: 1.5, + ccw: false, + constrain: 1, + })), 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"))), @@ -373,8 +394,8 @@ fn owners_are_the_space_the_entity_is_in() { let back = read_back(&tags); assert_eq!(back[0].common.owner, Some(fixed::PAPER_RECORD), "a paper space entity"); assert_eq!(back[1].common.owner, Some(fixed::MODEL_RECORD)); - let EntityKind::Insert(i) = &back[17].kind else { panic!("an insert") }; - assert_eq!(i.attribs[0].0.owner, back[17].common.handle, "an attribute is owned by its insert"); + let EntityKind::Insert(i) = &back[18].kind else { panic!("an insert") }; + assert_eq!(i.attribs[0].0.owner, back[18].common.handle, "an attribute is owned by its insert"); assert_eq!((back[0].common.handle, i.attribs[0].0.handle), (Some(0x500), Some(0x501)), "given handles are kept"); } @@ -592,7 +613,7 @@ fn the_file_around_the_content() { #[test] fn one_entity_for_a_file_the_caller_writes() { - let insert = sample(true).remove(17); + let insert = sample(true).remove(18); let mut next = 0x10; let tags = entity_tags(&Entity { common: Common { handle: Some(0x5), ..insert.common.clone() }, ..insert.clone() }, Format::R2018, 0x1F, &mut next);