craft-dxf: model objects (page setup, GROUP, IMAGEDEF, SORTENTSTABLE, SPATIAL_FILTER, DIMASSOC, SCALE, MLINESTYLE), HELIX, dimension overrides (#955)

* craft-dxf: model, the drawing above the tags (header, tables, blocks, layouts)

Document::read indexes a tag list into header variables, table records,
blocks with their entities, the ENTITIES records (a POLYLINE with its
vertices, an INSERT with its attributes) and the objects, each a slice of the
tag list. The typed table records, LAYOUT and DICTIONARY are made on request.
Also the colour index palette, colour, lineweight and transparency values,
extended data lookup and the object coordinate system.

Merged from CADCraft's cadcraft-io dxf_read (5a6aa9b6) and VectorCraft's
vectorcraft-cad import (566f7208). Neither app's document types appear.

* craft-dxf: model entities (geometry, text, MTEXT columns, hatches, dimensions)

Entity::read types an entity record: the properties every entity has, and
LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE, POLYLINE with its vertices
(2D, 3D, mesh, polyface), SPLINE, RAY, XLINE, TEXT, MTEXT with columns,
ATTDEF, INSERT with its attributes (multiline ones included), DIMENSION,
LEADER, SOLID, TRACE, 3DFACE, HATCH (polyline and edge loops, pattern lines,
seeds, gradient), VIEWPORT, IMAGE, WIPEOUT and TOLERANCE. Other types stay
readable from the record's tags. model::text has the caret encoding of
strings, both ways.

Merged from CADCraft's cadcraft-io dxf_read (5a6aa9b6) and VectorCraft's
vectorcraft-cad import (566f7208).

* craft-dxf: a hatch spline edge without fit data leaves the loop's source count

Before R2010 a spline edge has no 97 of its own, so the 97 after it counts the
loop's source entities. It was read as the edge's fit point count and the
sources were lost.

* craft-dxf: model::write, a drawing as tags (R12 to 2018)

Drawing::tags writes the header, classes, the nine symbol tables, blocks,
entities and (R2000 and later) the dictionaries, plot style placeholder and
layouts around the layers, linetypes, styles, dimension styles, blocks and
entities it is given, in the model's own types. entity_tags writes one entity
for a caller with its own file. Handles given with the content are kept;
the rest are numbered after them.

The file skeleton is VectorCraft's vectorcraft-cad writer.rs (566f7208); the
MTEXT column groups are CADCraft's dxf_read/mtext_dxf.rs column_tags
(5a6aa9b6).

* craft-dxf: model objects (page setup, groups, image definitions, draw order, clip filters, DIMASSOC, scales, multiline styles), HELIX and dimension overrides

model::objects types the OBJECTS records both readers and CADCraft's passes
use: the AcDbPlotSettings groups of a LAYOUT, GROUP, IMAGEDEF, SORTENTSTABLE,
SPATIAL_FILTER, DIMASSOC, SCALE and MLINESTYLE, and dim_overrides reads the
DSTYLE list of a dimension's ACAD extended data. Document gains objects_of and
dictionary_names (the name a dictionary lists an object under). HELIX is an
entity kind, read and written.

From CADCraft's cadcraft-io dxf_read/objects.rs, document.rs, dimassoc.rs,
entities.rs, dxf_mline.rs, dxf_clip.rs and dxf_image.rs (5a6aa9b6).

* README: keep main's crate list after the merge
This commit is contained in:
Dylan M. Taylor
2026-11-11 20:50:47 -05:00
committed by GitHub
parent 97f4971666
commit b85549a5b4
7 changed files with 644 additions and 7 deletions
+2 -1
View File
@@ -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};
+28
View File
@@ -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<Item = &'s Record<'a>> {
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<u64, &'a str> {
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<Dictionary> {
self.objects.iter().find(|r| r.kind == "DICTIONARY").map(Dictionary::read)
+38
View File
@@ -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)),
+398
View File
@@ -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<u64>,
/// 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<u64>,
}
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<u64>,
/// 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<u64>,
/// 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<SortEntsTable> {
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<u64>,
/// 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<f64>,
pub back: Option<f64>,
/// 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<f64> = 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<u64>,
/// 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<u64>,
/// 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<u64>,
/// Group 330 after the marker: the dimension.
pub dimension: Option<u64>,
/// 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<OsnapRef>,
}
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<usize> = 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<u64>,
/// 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<Scale> {
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<u64>,
/// 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<MLineElement>,
}
impl MLineStyle {
/// `None` for a style without a name.
pub fn read(r: &Record<'_>) -> Option<MLineStyle> {
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
}
+16
View File
@@ -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",
+135
View File
@@ -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<Tag> {
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<Scale> = 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::<Vec<_>>(), [("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));
}
+27 -6
View File
@@ -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<Entity> {
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);