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

* 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.

* README: keep main's crate list after the merge
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 19:21:50 -05:00
1 parent f233df14ad
commit e0a3b2b8c4
6 files changed
+1708 -3

No files matched your search

+1 -1
View File
@@ -78,7 +78,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-dxf`](crates/dxf/src/lib.rs): DXF tag reader and writer, from CADCraft: ASCII and
binary DXF as (group code, value) tags, split into sections and entities, with pre-2007 code
pages and version detection, and written back; and `model`, the drawing above the tags (header
variables, tables, blocks, layouts), merged from CADCraft's and VectorCraft's readers. Bounded
variables, tables, blocks, layouts, entities), merged from CADCraft's and VectorCraft's readers. Bounded
on hostile input. Mapping the model onto a document stays in each app.
- [`craft-ecproj-format`](crates/ecproj-format/README.md): the envelope of EffectCraft `.ecproj`
project files: `savedBy`, the schema version and its check, and the file object with the
+10 -2
View File
@@ -3,8 +3,8 @@
//! [`Document::read`] indexes a tag list (from [`crate::parse`]) into its header variables,
//! symbol tables, blocks, entities and objects. Nothing is copied: every record borrows its tags,
//! so a caller can still read a group this module gives no name to (its own extended data, a
//! newer group). The typed forms ([`Layer`], [`Linetype`], [`TextStyle`], [`Layout`]...) are made
//! on request.
//! newer group). The typed forms ([`Layer`], [`Linetype`], [`TextStyle`], [`Layout`], and
//! [`Entity`] for an entity record) are made on request.
//!
//! The values are the file's: points in the coordinate system the format stores them in (the
//! object coordinate system for planar entities, see [`ocs_to_wcs`]), angles in the unit the
@@ -18,10 +18,18 @@ use crate::Tag;
pub mod aci;
mod document;
mod entity;
mod hatch;
mod tables;
pub mod text;
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,
};
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};
pub use text::{MText, MTextColumns};
/// A point or vector: x, y, z.
pub type Point = [f64; 3];
+669
View File
@@ -0,0 +1,669 @@
//! Entities, typed: the properties every entity has ([`Common`]) and the geometry of each type.
//!
//! Values are as the file stores them. Planar entities (CIRCLE, ARC, LWPOLYLINE, 2D POLYLINE,
//! TEXT, INSERT, SOLID, TRACE, HATCH) have their points in the object coordinate system of
//! [`Common::extrusion`] ([`super::ocs_to_wcs`]); LINE, POINT, ELLIPSE, SPLINE, RAY, XLINE,
//! 3DFACE, MTEXT, LEADER and DIMENSION definition points are world coordinates. Angles are in
//! degrees, except an ELLIPSE's parameters, which the format stores in radians.
use super::document::{EntityRecord, Record};
use super::hatch::Hatch;
use super::text::{MText, decode_line, split_embedded};
use super::{Color, Groups, Lineweight, Point, Transparency, outside_app, parse_handle};
use crate::Tag;
/// The properties every entity has.
#[derive(Clone, Debug, PartialEq)]
pub struct Common {
pub handle: Option<u64>,
/// Group 330: the block record (or other object) that owns the entity.
pub owner: Option<u64>,
/// Group 8 (`0` when absent).
pub layer: String,
/// Group 6; `None` is ByLayer.
pub linetype: Option<String>,
pub color: Color,
pub lineweight: Lineweight,
/// Group 48 (1 when absent or not positive).
pub ltscale: f64,
/// Group 60 is not 1.
pub visible: bool,
/// Group 67 is 1.
pub paper_space: bool,
pub transparency: Transparency,
/// Group 39.
pub thickness: f64,
/// Groups 210/220/230 (the world Z axis when absent).
pub extrusion: Point,
/// Group 390: the plot style name object.
pub plot_style: Option<u64>,
}
/// The groups of the AcDbEntity subclass: the record up to the entity's own subclass marker (all
/// of it in files without markers, and in records with no AcDbEntity marker, whose layer and
/// colour may follow the entity's marker), or up to an embedded object (101). Later groups reuse
/// the codes: an MTEXT's column width is 48. Markers inside an application-defined group do not
/// count.
fn entity_groups(tags: &[Tag]) -> &[Tag] {
let is_entity = |t: &Tag| t.str_ref().is_some_and(|s| s == "AcDbEntity");
let marked = outside_app(tags).any(|(_, t)| t.code == 100 && is_entity(t));
let end = outside_app(tags).find(|(_, t)| (marked && t.code == 100 && !is_entity(t)) || t.code == 101).map_or(tags.len(), |(i, _)| i);
tags.get(..end).unwrap_or(tags)
}
impl Common {
pub fn read(tags: &[Tag]) -> Common {
let (all, e) = (Groups(tags), Groups(entity_groups(tags)));
Common {
handle: e.handle(5),
owner: outside_app(tags).find(|(_, t)| t.code == 330).and_then(|(_, t)| parse_handle(&t.str())),
layer: e.name(8).unwrap_or_else(|| "0".into()),
linetype: e.name(6).filter(|l| !l.eq_ignore_ascii_case("ByLayer")),
color: Color::of(&e),
lineweight: e.i(370).map_or(Lineweight::ByLayer, Lineweight::from_dxf),
ltscale: e.f(48).filter(|s| *s > 0.0).unwrap_or(1.0),
visible: e.i(60) != Some(1),
paper_space: e.i(67) == Some(1),
transparency: e.i(440).map_or(Transparency::ByLayer, Transparency::from_dxf),
thickness: all.f_or(39, 0.0),
extrusion: [all.f_or(210, 0.0), all.f_or(220, 0.0), all.f_or(230, 1.0)],
plot_style: e.handle(390).filter(|h| *h != 0),
}
}
}
/// A vertex of an LWPOLYLINE.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct LwVertex {
pub x: f64,
pub y: f64,
/// Group 42: the tangent of a quarter of the arc to the next vertex (0: straight).
pub bulge: f64,
pub start_width: f64,
pub end_width: f64,
}
/// An LWPOLYLINE.
#[derive(Clone, Debug, PartialEq)]
pub struct LwPolyline {
pub vertices: Vec<LwVertex>,
/// Flags (70) bit 1.
pub closed: bool,
/// Flags bit 128: the linetype runs around the vertices.
pub plinegen: bool,
/// Group 43.
pub const_width: f64,
/// Group 38.
pub elevation: f64,
}
/// A VERTEX of a POLYLINE.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Vertex {
pub location: Point,
pub bulge: f64,
pub start_width: f64,
pub end_width: f64,
/// Group 70: 1 added by curve fitting, 8 added by spline fitting, 16 a spline frame control
/// point, 32 a 3D polyline vertex, 64 a mesh vertex, 128 a polyface vertex (128 without 64:
/// a face record).
pub flags: i64,
/// Groups 71–74: the vertex numbers of a polyface face record (1-based; negative: the edge
/// from it is invisible; 0: none).
pub face: [i64; 4],
}
impl Vertex {
/// A face record of a polyface mesh, which holds vertex numbers and no point.
pub fn is_face(&self) -> bool {
self.flags & 128 != 0 && self.flags & 64 == 0
}
/// A control point of the frame a spline-fit polyline was made from: not on the curve.
pub fn is_spline_frame(&self) -> bool {
self.flags & 16 != 0
}
}
/// A POLYLINE with its VERTEX records: a 2D or 3D polyline, a polygon mesh or a polyface mesh.
#[derive(Clone, Debug, PartialEq)]
pub struct Polyline {
/// Group 70: 1 closed (a mesh: closed in M), 2 curve-fit, 4 spline-fit, 8 a 3D polyline, 16 a
/// polygon mesh, 32 a mesh closed in N, 64 a polyface mesh, 128 the linetype runs around the
/// vertices.
pub flags: i64,
/// The z of the POLYLINE's own point: a 2D polyline's elevation.
pub elevation: f64,
/// Groups 40 and 41: the default widths.
pub start_width: f64,
pub end_width: f64,
/// Groups 71 and 72: a mesh's M and N vertex counts (a polyface mesh: vertices and faces).
pub m: i64,
pub n: i64,
/// Group 75: the smooth surface type (5 quadratic, 6 cubic, 8 Bezier).
pub smooth: i64,
pub vertices: Vec<Vertex>,
}
impl Polyline {
pub fn closed(&self) -> bool {
self.flags & 1 != 0
}
pub fn is_3d(&self) -> bool {
self.flags & 8 != 0
}
pub fn is_mesh(&self) -> bool {
self.flags & 16 != 0
}
pub fn is_polyface(&self) -> bool {
self.flags & 64 != 0
}
}
/// A SPLINE (NURBS), in world coordinates.
#[derive(Clone, Debug, PartialEq)]
pub struct Spline {
/// Group 71 (3 when absent).
pub degree: i64,
/// Group 70: 1 closed, 2 periodic, 4 rational, 8 planar, 16 linear.
pub flags: i64,
pub knots: Vec<f64>,
pub control: Vec<Point>,
/// Group 41: one per control point when rational.
pub weights: Vec<f64>,
pub fit: Vec<Point>,
/// Groups 12/22/32 and 13/23/33.
pub start_tangent: Option<Point>,
pub end_tangent: Option<Point>,
/// Group 44.
pub fit_tolerance: f64,
}
/// TEXT, and the text of an ATTRIB or ATTDEF.
#[derive(Clone, Debug, PartialEq)]
pub struct Text {
/// Group 10: the first alignment point.
pub insert: Point,
/// Group 11: the second alignment point, which places text that is not left-baseline.
pub align: Option<Point>,
pub height: f64,
/// The string, decoded ([`decode_line`]); `%%` codes are left as written.
pub value: String,
/// Group 50, in degrees.
pub rotation: f64,
/// Group 41 (1 when absent).
pub width_factor: f64,
/// Group 51, in degrees.
pub oblique: f64,
/// Group 7 (`Standard` when absent).
pub style: String,
/// Group 71: 2 backwards, 4 upside down.
pub generation: i64,
/// Group 72: 0 left, 1 centre, 2 right, 3 aligned, 4 middle, 5 fit.
pub halign: i64,
/// Group 73 (74 in an ATTRIB or ATTDEF): 0 baseline, 1 bottom, 2 middle, 3 top.
pub valign: i64,
}
impl Text {
pub fn read(g: &Groups<'_>) -> Text {
Text {
insert: g.point(10),
align: g.point_opt(11),
height: g.f_or(40, 0.0),
value: decode_line(&g.s(1).unwrap_or_default()),
rotation: g.f_or(50, 0.0),
width_factor: g.f_or(41, 1.0),
oblique: g.f_or(51, 0.0),
style: g.name(7).unwrap_or_else(|| "Standard".into()),
generation: g.i_or(71, 0),
halign: g.i_or(72, 0),
valign: g.i(73).or_else(|| g.i(74)).unwrap_or(0),
}
}
}
/// An ATTRIB (an attribute of a block reference) or ATTDEF (its definition in the block).
#[derive(Clone, Debug, PartialEq)]
pub struct Attrib {
/// Group 2.
pub tag: String,
pub text: Text,
/// Group 3 of an ATTDEF, decoded; empty in an ATTRIB.
pub prompt: String,
/// Group 70: 1 invisible, 2 constant, 4 verify, 8 preset.
pub flags: i64,
/// The 280 after the tag in the attribute's subclass (one before the tag is the subclass
/// version).
pub lock_position: bool,
/// The embedded MTEXT of a multiline attribute.
pub multiline: Option<MText>,
}
impl Attrib {
pub fn read(kind: &str, tags: &[Tag]) -> Attrib {
// A multiline attribute has its text as embedded MTEXT after its own groups.
let (own, embedded) = split_embedded(tags);
let g = Groups(own);
let sub = own.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim().starts_with("AcDbAttribute"))).unwrap_or(0);
let rest = own.get(sub..).unwrap_or_default();
let lock_position = rest.iter().position(|t| t.code == 2).is_some_and(|at| rest.iter().skip(at + 1).any(|t| t.code == 280 && t.i64() != 0));
Attrib {
tag: g.s(2).unwrap_or_default(),
text: Text::read(&g),
prompt: if kind == "ATTDEF" { decode_line(&g.s(3).unwrap_or_default()) } else { String::new() },
flags: g.i_or(70, 0),
lock_position,
multiline: embedded.filter(|e| !e.is_empty()).map(MText::read),
}
}
}
/// An INSERT: a block reference, with its attributes.
#[derive(Clone, Debug, PartialEq)]
pub struct Insert {
/// Group 2.
pub block: String,
pub insert: Point,
/// Groups 41, 42 and 43 (1 when absent).
pub scale: Point,
/// Group 50, in degrees.
pub rotation: f64,
/// Groups 70 and 71 (1 when absent or less).
pub columns: i64,
pub rows: i64,
/// Groups 44 and 45.
pub column_spacing: f64,
pub row_spacing: f64,
/// The ATTRIB records that follow, each with its own properties.
pub attribs: Vec<(Common, Attrib)>,
}
/// A DIMENSION. The geometry it is drawn with is in its block ([`Dimension::block`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Dimension {
/// Group 70: the type in the low bits (0 rotated, 1 aligned, 2 angular, 3 diameter, 4 radius,
/// 5 angular 3-point, 6 ordinate), 32 the block is this dimension's alone, 64 an ordinate's
/// X type, 128 the text is at a user position.
pub flags: i64,
/// Group 2: the block that holds the dimension's picture.
pub block: Option<String>,
/// Group 3 (`Standard` when absent).
pub style: String,
/// Group 10: the definition point.
pub defpt: Point,
/// Group 11: the middle of the text, in the object coordinate system.
pub text_mid: Point,
/// Group 12: the insertion point for clones (baseline and continue).
pub insertion: Point,
/// Groups 13 to 16: the definition points of the type.
pub p13: Point,
pub p14: Point,
pub p15: Point,
pub p16: Point,
/// Group 1, decoded: empty or `<>` for the measurement, a space for none.
pub text: String,
/// Group 42: the measurement, when the file states it.
pub measurement: Option<f64>,
/// Group 50: the angle of a rotated dimension, in degrees.
pub rotation: f64,
/// Group 52: the oblique angle of the extension lines, in degrees.
pub oblique: f64,
/// Group 53: the text's rotation, in degrees.
pub text_rotation: f64,
/// Group 51: the horizontal direction, in degrees.
pub horizontal: f64,
/// Group 40: the leader length of a radius or diameter dimension.
pub leader_length: f64,
}
impl Dimension {
/// The type: the low four bits of the flags.
pub fn kind(&self) -> i64 {
self.flags & 0x0f
}
}
/// A LEADER.
#[derive(Clone, Debug, PartialEq)]
pub struct Leader {
pub vertices: Vec<Point>,
/// Group 71 is not 0.
pub arrow: bool,
/// Group 72 is not 0: a spline path.
pub spline: bool,
/// Group 3 (`Standard` when absent).
pub style: String,
/// Group 73: what the leader was made with (0 text, 1 a tolerance, 2 a block, 3 nothing).
pub annotation_type: i64,
/// Group 340: the annotation.
pub annotation: Option<u64>,
}
/// A paper space VIEWPORT.
#[derive(Clone, Debug, PartialEq)]
pub struct Viewport {
/// Group 10: the centre on the sheet.
pub center: Point,
/// Groups 40 and 41: the size on the sheet.
pub width: f64,
pub height: f64,
/// Group 69: the id (1 is the sheet's own view). Other programs write -1 for unknown.
pub id: i64,
/// Group 90: the status flags (16384 locked, 65536 clipped by the entity of group 340,
/// 131072 off).
pub status: i64,
/// Groups 12/22: the view centre in display coordinates, whose origin is the target.
pub view_center: [f64; 2],
/// Groups 16/26/36 and 17/27/37.
pub view_direction: Point,
pub view_target: Point,
/// Group 45.
pub view_height: f64,
/// Group 51: the twist, in degrees.
pub twist: f64,
/// Group 340: the entity that clips the viewport.
pub clip: Option<u64>,
/// Groups 331 and 341: the layers frozen in the viewport.
pub frozen_layers: Vec<u64>,
}
/// An IMAGE or WIPEOUT: a raster's place and clip. The file is named by the IMAGEDEF object.
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// Group 10: the lower-left corner.
pub insert: Point,
/// Groups 11 and 12: the vectors of one pixel along the image's width and height.
pub u: Point,
pub v: Point,
/// Groups 13/23: the size in pixels.
pub size: [f64; 2],
/// Group 340: the IMAGEDEF.
pub image_def: Option<u64>,
/// Group 70: 1 show, 2 show when not aligned with the screen, 4 use the clip, 8 transparency.
pub display: i64,
/// Group 280 is not 0.
pub clipping: bool,
/// Groups 281, 282 and 283 (50, 50 and 0 when absent).
pub brightness: i64,
pub contrast: i64,
pub fade: i64,
/// Group 71: 1 a rectangle (two corners), 2 a polygon.
pub clip_type: i64,
/// Groups 14/24: the clip boundary in pixels, with the origin at the image's top-left corner
/// and y down: a point `q` is at `insert + u * (q.x + 0.5) + v * (0.5 - q.y)`.
pub clip: Vec<[f64; 2]>,
/// Group 290 is not 0: the clip keeps the outside.
pub clip_inverted: bool,
}
/// The geometry of an entity.
#[derive(Clone, Debug, PartialEq)]
pub enum EntityKind {
Line {
start: Point,
end: Point,
},
/// `angle` is group 50: the X axis of the point's symbol, in degrees.
Point {
location: Point,
angle: f64,
},
Circle {
center: Point,
radius: f64,
},
Arc {
center: Point,
radius: f64,
start_angle: f64,
end_angle: f64,
},
/// `major` is the end of the major axis from the centre; `start` and `end` are parameters in
/// radians (0 and 2π for a full ellipse).
Ellipse {
center: Point,
major: Point,
ratio: f64,
start: f64,
end: f64,
},
LwPolyline(LwPolyline),
Polyline(Polyline),
Spline(Spline),
Ray {
base: Point,
direction: Point,
},
XLine {
base: Point,
direction: Point,
},
Text(Text),
MText(MText),
AttDef(Attrib),
Insert(Insert),
Dimension(Dimension),
Leader(Leader),
/// The fourth corner repeats the third when the file has three.
Solid {
corners: [Point; 4],
},
Trace {
corners: [Point; 4],
},
/// `hidden_edges` is group 70: bit n hides edge n.
Face3d {
corners: [Point; 4],
hidden_edges: i64,
},
Hatch(Hatch),
Viewport(Viewport),
Image(Image),
Wipeout(Image),
/// `text` is the feature control frame's string, as written.
Tolerance {
insert: Point,
direction: Point,
style: String,
text: String,
},
/// A type this module does not model (or a HATCH with no boundary data): read it from the
/// record's tags.
Other,
}
/// An entity: its properties and geometry.
#[derive(Clone, Debug, PartialEq)]
pub struct Entity {
pub common: Common,
pub kind: EntityKind,
}
fn lwpolyline(tags: &[Tag], g: &Groups<'_>) -> LwPolyline {
let mut vertices: Vec<LwVertex> = Vec::new();
for t in tags {
if t.code == 10 {
vertices.push(LwVertex { x: t.f64(), ..LwVertex::default() });
} else if let Some(v) = vertices.last_mut() {
match t.code {
20 => v.y = t.f64(),
40 => v.start_width = t.f64(),
41 => v.end_width = t.f64(),
42 => v.bulge = t.f64(),
_ => {}
}
}
}
let flags = g.i_or(70, 0);
LwPolyline { vertices, closed: flags & 1 != 0, plinegen: flags & 128 != 0, const_width: g.f_or(43, 0.0), elevation: g.f_or(38, 0.0) }
}
fn polyline(g: &Groups<'_>, followers: &[Record<'_>]) -> Polyline {
let vertices = followers
.iter()
.map(|r| {
let v = r.groups();
Vertex {
location: v.point(10),
bulge: v.f_or(42, 0.0),
start_width: v.f_or(40, 0.0),
end_width: v.f_or(41, 0.0),
flags: v.i_or(70, 0),
face: [71, 72, 73, 74].map(|c| v.i_or(c, 0)),
}
})
.collect();
Polyline {
flags: g.i_or(70, 0),
elevation: g.f_or(30, 0.0),
start_width: g.f_or(40, 0.0),
end_width: g.f_or(41, 0.0),
m: g.i_or(71, 0),
n: g.i_or(72, 0),
smooth: g.i_or(75, 0),
vertices,
}
}
fn spline(g: &Groups<'_>) -> Spline {
Spline {
degree: g.i_or(71, 3),
flags: g.i_or(70, 0),
knots: g.all_f(40),
control: g.points(10),
weights: g.all_f(41),
fit: g.points(11),
start_tangent: g.point_opt(12),
end_tangent: g.point_opt(13),
fit_tolerance: g.f_or(44, 0.0),
}
}
fn insert(g: &Groups<'_>, followers: &[Record<'_>]) -> Insert {
Insert {
block: g.s(2).unwrap_or_default(),
insert: g.point(10),
scale: [g.f_or(41, 1.0), g.f_or(42, 1.0), g.f_or(43, 1.0)],
rotation: g.f_or(50, 0.0),
columns: g.i_or(70, 1).max(1),
rows: g.i_or(71, 1).max(1),
column_spacing: g.f_or(44, 0.0),
row_spacing: g.f_or(45, 0.0),
attribs: followers.iter().map(|r| (Common::read(r.tags), Attrib::read("ATTRIB", r.tags))).collect(),
}
}
fn dimension(g: &Groups<'_>) -> Dimension {
Dimension {
flags: g.i_or(70, 0),
block: g.name(2),
style: g.name(3).unwrap_or_else(|| "Standard".into()),
defpt: g.point(10),
text_mid: g.point(11),
insertion: g.point(12),
p13: g.point(13),
p14: g.point(14),
p15: g.point(15),
p16: g.point(16),
text: decode_line(&g.s(1).unwrap_or_default()),
measurement: g.f(42),
rotation: g.f_or(50, 0.0),
oblique: g.f_or(52, 0.0),
text_rotation: g.f_or(53, 0.0),
horizontal: g.f_or(51, 0.0),
leader_length: g.f_or(40, 0.0),
}
}
fn viewport(tags: &[Tag], g: &Groups<'_>) -> Viewport {
Viewport {
center: g.point(10),
width: g.f_or(40, 0.0),
height: g.f_or(41, 0.0),
id: g.i_or(69, 0),
status: g.i_or(90, 0),
view_center: [g.f_or(12, 0.0), g.f_or(22, 0.0)],
view_direction: g.point_opt(16).unwrap_or([0.0, 0.0, 1.0]),
view_target: g.point(17),
view_height: g.f_or(45, 0.0),
twist: g.f_or(51, 0.0),
clip: g.handle(340).filter(|h| *h != 0),
frozen_layers: tags.iter().filter(|t| matches!(t.code, 331 | 341)).filter_map(|t| parse_handle(&t.str())).collect(),
}
}
fn image(g: &Groups<'_>) -> Image {
Image {
insert: g.point(10),
u: g.point(11),
v: g.point(12),
size: [g.f_or(13, 0.0), g.f_or(23, 0.0)],
image_def: g.handle(340).filter(|h| *h != 0),
display: g.i_or(70, 0),
clipping: g.i_or(280, 0) != 0,
brightness: g.i_or(281, 50),
contrast: g.i_or(282, 50),
fade: g.i_or(283, 0),
clip_type: g.i_or(71, 1),
clip: g.points(14).into_iter().map(|p| [p[0], p[1]]).collect(),
clip_inverted: g.i_or(290, 0) != 0,
}
}
fn corners(g: &Groups<'_>) -> [Point; 4] {
[g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or_else(|| g.point(12))]
}
impl Entity {
/// The typed form of an entity record.
pub fn read(e: &EntityRecord<'_>) -> Entity {
let g = e.groups();
let line = |g: &Groups<'_>| (g.point(10), g.point(11));
let kind = match e.kind {
"LINE" => EntityKind::Line { start: line(&g).0, end: line(&g).1 },
"POINT" => EntityKind::Point { location: g.point(10), angle: g.f_or(50, 0.0) },
"CIRCLE" => EntityKind::Circle { center: g.point(10), radius: g.f_or(40, 0.0) },
"ARC" => EntityKind::Arc { center: g.point(10), radius: g.f_or(40, 0.0), start_angle: g.f_or(50, 0.0), end_angle: g.f_or(51, 360.0) },
"ELLIPSE" => EntityKind::Ellipse {
center: g.point(10),
major: g.point(11),
ratio: g.f_or(40, 1.0),
start: g.f_or(41, 0.0),
end: g.f_or(42, std::f64::consts::TAU),
},
"LWPOLYLINE" => EntityKind::LwPolyline(lwpolyline(e.tags, &g)),
"POLYLINE" => EntityKind::Polyline(polyline(&g, &e.followers)),
"SPLINE" => EntityKind::Spline(spline(&g)),
"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)),
"MTEXT" => EntityKind::MText(MText::read(e.tags)),
"ATTDEF" => EntityKind::AttDef(Attrib::read("ATTDEF", e.tags)),
"INSERT" => EntityKind::Insert(insert(&g, &e.followers)),
"DIMENSION" => EntityKind::Dimension(dimension(&g)),
"LEADER" => EntityKind::Leader(Leader {
vertices: g.points(10),
arrow: g.i_or(71, 1) != 0,
spline: g.i_or(72, 0) != 0,
style: g.name(3).unwrap_or_else(|| "Standard".into()),
annotation_type: g.i_or(73, 3),
annotation: g.handle(340).filter(|h| *h != 0),
}),
"SOLID" => EntityKind::Solid { corners: corners(&g) },
"TRACE" => EntityKind::Trace { corners: corners(&g) },
"3DFACE" => EntityKind::Face3d { corners: corners(&g), hidden_edges: g.i_or(70, 0) },
"HATCH" => Hatch::read(e.tags).map_or(EntityKind::Other, EntityKind::Hatch),
"VIEWPORT" => EntityKind::Viewport(viewport(e.tags, &g)),
"IMAGE" => EntityKind::Image(image(&g)),
"WIPEOUT" => EntityKind::Wipeout(image(&g)),
"TOLERANCE" => EntityKind::Tolerance {
insert: g.point(10),
direction: g.point_opt(11).unwrap_or([1.0, 0.0, 0.0]),
style: g.name(3).unwrap_or_else(|| "Standard".into()),
text: g.s(1).unwrap_or_default(),
},
_ => EntityKind::Other,
};
Entity { common: Common::read(e.tags), kind }
}
}
+355
View File
@@ -0,0 +1,355 @@
//! HATCH: the boundary loops (polylines or edges), the pattern definition, the seed points and
//! the gradient, as the file holds them. Boundary points are 2D, in the object coordinate system
//! of the hatch at its elevation.
use super::{Color, Groups, parse_handle};
use crate::Tag;
/// One edge of a boundary loop made of edges.
#[derive(Clone, Debug, PartialEq)]
pub enum HatchEdge {
Line {
start: [f64; 2],
end: [f64; 2],
},
/// Angles in degrees; a clockwise arc (`ccw` false) has them measured clockwise.
Arc {
center: [f64; 2],
radius: f64,
start_angle: f64,
end_angle: f64,
ccw: bool,
},
/// `major` is the end of the major axis from the centre, `ratio` the minor axis's length
/// over the major's; angles in degrees, as for an arc.
Ellipse {
center: [f64; 2],
major: [f64; 2],
ratio: f64,
start_angle: f64,
end_angle: f64,
ccw: bool,
},
Spline {
degree: i64,
rational: bool,
periodic: bool,
knots: Vec<f64>,
control: Vec<[f64; 2]>,
/// One per control point when rational; else empty.
weights: Vec<f64>,
fit: Vec<[f64; 2]>,
start_tangent: Option<[f64; 2]>,
end_tangent: Option<[f64; 2]>,
},
}
/// The path of a boundary loop.
#[derive(Clone, Debug, PartialEq)]
pub enum HatchPath {
/// Vertices as x, y and bulge.
Polyline {
closed: bool,
vertices: Vec<[f64; 3]>,
},
Edges(Vec<HatchEdge>),
}
/// A boundary loop.
#[derive(Clone, Debug, PartialEq)]
pub struct HatchLoop {
/// Group 92: 1 external, 2 a polyline, 4 derived, 8 a text box, 16 outermost.
pub flags: i64,
pub path: HatchPath,
/// Groups 330 after the loop's 97: the entities an associative hatch follows.
pub sources: Vec<u64>,
}
/// One line family of a pattern definition, as drawn: scaled and rotated with the hatch.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct PatternLine {
/// Group 53, in degrees.
pub angle: f64,
/// Groups 43/44: a point the first line passes through.
pub base: [f64; 2],
/// Groups 45/46: the step from one line to the next, as a vector.
pub offset: [f64; 2],
/// Groups 49: dash (positive), gap (negative) and dot (0) lengths; empty for a solid line.
pub dashes: Vec<f64>,
}
/// A gradient fill (groups 450–470).
#[derive(Clone, Debug, PartialEq)]
pub struct Gradient {
/// Group 470 (`LINEAR`, `CYLINDER`, `SPHERICAL`...).
pub name: String,
/// Group 460, in radians.
pub angle: f64,
/// Group 461: 0 centred, 1 shifted up and left.
pub shift: f64,
/// Group 452 is 1: one colour, shaded by `tint` (group 462).
pub single: bool,
pub tint: f64,
/// The colour of each 463 record: its true colour (421), else its index (63).
pub colors: Vec<Color>,
}
/// A HATCH.
#[derive(Clone, Debug, PartialEq)]
pub struct Hatch {
/// Group 2, as written (`SOLID` when absent).
pub pattern: String,
/// Group 70 is 1.
pub solid: bool,
/// Group 71 is not 0.
pub associative: bool,
/// Group 75: 0 normal (odd parity), 1 outer, 2 ignore islands.
pub style: i64,
/// Group 76: 0 user-defined, 1 predefined, 2 custom.
pub pattern_type: i64,
/// Group 52, in degrees.
pub angle: f64,
/// Group 41 (1 when absent).
pub scale: f64,
/// Group 77 is not 0: a user-defined pattern is doubled (crossed).
pub double: bool,
/// The z of the elevation point (the first group 30).
pub elevation: f64,
pub loops: Vec<HatchLoop>,
pub pattern_lines: Vec<PatternLine>,
/// Groups 10/20 after the 98 count.
pub seeds: Vec<[f64; 2]>,
pub gradient: Option<Gradient>,
}
/// A cursor over a record's tags, for the boundary data, which is read in order.
struct Cur<'a> {
tags: &'a [Tag],
at: usize,
}
impl<'a> Cur<'a> {
fn peek(&self) -> Option<&'a Tag> {
self.tags.get(self.at)
}
/// The next tag when its code is `code`, consumed.
fn take(&mut self, code: i32) -> Option<&'a Tag> {
let t = self.peek().filter(|t| t.code == code)?;
self.at += 1;
Some(t)
}
fn f(&mut self, code: i32) -> f64 {
self.take(code).map_or(0.0, Tag::f64)
}
fn xy(&mut self, code: i32) -> [f64; 2] {
[self.f(code), self.f(code + 10)]
}
/// Move to the next `code`; false when there is none.
fn seek(&mut self, code: i32) -> bool {
while self.peek().is_some_and(|t| t.code != code) {
self.at += 1;
}
self.peek().is_some()
}
}
/// A declared count: never trusted for an allocation, only as the most items to read.
fn count(t: &Tag) -> usize {
usize::try_from(t.i64()).unwrap_or(0)
}
fn polyline_path(c: &mut Cur<'_>) -> Option<HatchPath> {
// 72 has bulges, 73 closed, 93 the vertex count.
let mut closed = false;
while let Some(t) = c.peek().filter(|t| t.code != 93) {
if t.code == 73 {
closed = t.i64() != 0;
} else if t.code != 72 {
return None;
}
c.at += 1;
}
let n = count(c.take(93)?);
let mut vertices = Vec::new();
while vertices.len() < n && c.peek().is_some_and(|t| t.code == 10) {
let [x, y] = c.xy(10);
vertices.push([x, y, c.f(42)]);
}
Some(HatchPath::Polyline { closed, vertices })
}
fn spline_edge(c: &mut Cur<'_>) -> HatchEdge {
let (mut degree, mut rational, mut periodic, mut knot_count, mut control_count) = (3, false, false, 0, 0);
while let Some(t) = c.peek().filter(|t| matches!(t.code, 94 | 73 | 74 | 95 | 96)) {
match t.code {
94 => degree = t.i64(),
73 => rational = t.i64() != 0,
74 => periodic = t.i64() != 0,
95 => knot_count = count(t),
_ => control_count = count(t),
}
c.at += 1;
}
let mut knots = Vec::new();
while knots.len() < knot_count && c.peek().is_some_and(|t| t.code == 40) {
knots.push(c.f(40));
}
let (mut control, mut weights) = (Vec::new(), Vec::new());
while control.len() < control_count && c.peek().is_some_and(|t| t.code == 10) {
control.push(c.xy(10));
weights.extend(c.take(42).map(Tag::f64));
}
if weights.len() != control.len() {
weights.clear();
}
// A 97 here is the edge's fit point count (R2010 and later) when fit points follow it, or
// when it is 0 and the loop's own 97 comes next; otherwise it is the loop's source count.
let mut fit = Vec::new();
let next = c.tags.get(c.at + 1).map(|t| t.code);
let fit_count = c.peek().filter(|t| t.code == 97 && (next == Some(11) || (t.i64() == 0 && next == Some(97))));
if let Some(n) = fit_count.map(count) {
c.at += 1;
while fit.len() < n && c.peek().is_some_and(|t| t.code == 11) {
fit.push(c.xy(11));
}
}
let tangent = |c: &mut Cur<'_>, code: i32| c.peek().is_some_and(|t| t.code == code).then(|| c.xy(code));
let start_tangent = tangent(c, 12);
let end_tangent = tangent(c, 13);
HatchEdge::Spline { degree, rational, periodic, knots, control, weights, fit, start_tangent, end_tangent }
}
fn edge_path(c: &mut Cur<'_>) -> Option<HatchPath> {
if !c.seek(93) {
return None;
}
let n = count(c.take(93)?);
let mut edges = Vec::new();
while edges.len() < n {
// Groups this reader does not know may sit between edges; the loop's own end may not.
while c.peek().is_some_and(|t| !matches!(t.code, 72 | 97 | 92 | 75)) {
c.at += 1;
}
if c.peek().is_none_or(|t| t.code != 72) {
break;
}
let kind = c.take(72).map_or(0, Tag::i64);
// 73 is counter-clockwise unless it says 0.
let ccw = |c: &mut Cur<'_>| c.take(73).is_none_or(|t| t.i64() != 0);
edges.push(match kind {
1 => HatchEdge::Line { start: c.xy(10), end: c.xy(11) },
2 => HatchEdge::Arc { center: c.xy(10), radius: c.f(40), start_angle: c.f(50), end_angle: c.f(51), ccw: ccw(c) },
3 => HatchEdge::Ellipse { center: c.xy(10), major: c.xy(11), ratio: c.f(40), start_angle: c.f(50), end_angle: c.f(51), ccw: ccw(c) },
4 => spline_edge(c),
// An edge type the format does not have: its groups are passed over above.
_ => continue,
});
}
Some(HatchPath::Edges(edges))
}
fn pattern_lines(tags: &[Tag]) -> Vec<PatternLine> {
let Some(at) = tags.iter().position(|t| t.code == 78) else { return Vec::new() };
let mut lines: Vec<PatternLine> = Vec::new();
for t in tags.iter().skip(at + 1) {
if t.code == 53 {
lines.push(PatternLine { angle: t.f64(), ..PatternLine::default() });
continue;
}
let Some(l) = lines.last_mut() else { break };
match t.code {
43 => l.base[0] = t.f64(),
44 => l.base[1] = t.f64(),
45 => l.offset[0] = t.f64(),
46 => l.offset[1] = t.f64(),
79 => {}
49 => l.dashes.push(t.f64()),
_ => break,
}
}
lines
}
/// Groups 450–470, when 450 says gradient. Each 463 starts a colour record whose 63 (index) and
/// 421 (true colour) follow.
fn gradient(tags: &[Tag]) -> Option<Gradient> {
let start = tags.iter().position(|t| t.code == 450)?;
if tags.get(start)?.i64() != 1 {
return None;
}
let mut g = Gradient { name: String::new(), angle: 0.0, shift: 0.0, single: false, tint: 0.0, colors: Vec::new() };
for t in tags.iter().skip(start + 1).take_while(|t| t.code != 1001) {
match t.code {
452 => g.single = t.i64() == 1,
460 => g.angle = t.f64(),
461 => g.shift = t.f64(),
462 => g.tint = t.f64(),
463 => g.colors.push(Color::ByLayer),
63 => {
// A true colour already read for this record wins over its index.
if let Some(c) = g.colors.last_mut().filter(|c| !matches!(c, Color::Rgb(_))) {
*c = Color::from_index(t.i64());
}
}
421 => {
if let Some(c) = g.colors.last_mut() {
*c = Color::from_true(t.i64());
}
}
470 => g.name = t.str(),
_ => {}
}
}
Some(g)
}
impl Hatch {
/// The typed form of a HATCH record; `None` when it has no boundary data (no group 91) or
/// the data ends inside a loop.
pub fn read(tags: &[Tag]) -> Option<Hatch> {
let g = Groups(tags);
let n_loops = count(tags.iter().find(|t| t.code == 91)?);
let mut c = Cur { tags, at: tags.iter().position(|t| t.code == 91)? + 1 };
let mut loops = Vec::new();
while loops.len() < n_loops {
if !c.seek(92) {
return None;
}
let flags = c.take(92)?.i64();
let path = if flags & 2 != 0 { polyline_path(&mut c)? } else { edge_path(&mut c)? };
// 97 counts the source entities (330) that follow.
let mut sources = Vec::new();
if let Some(n) = c.take(97).map(count) {
while sources.len() < n
&& let Some(t) = c.take(330)
{
sources.extend(parse_handle(&t.str()).filter(|h| *h != 0));
}
}
loops.push(HatchLoop { flags, path, sources });
}
let mut seeds = Vec::new();
if c.seek(98) {
let n = c.take(98).map_or(0, count);
while seeds.len() < n && c.peek().is_some_and(|t| t.code == 10) {
seeds.push(c.xy(10));
}
}
let pattern = g.name(2).unwrap_or_else(|| "SOLID".into());
Some(Hatch {
solid: g.i_or(70, 0) == 1,
associative: g.i_or(71, 0) != 0,
style: g.i_or(75, 0),
pattern_type: g.i_or(76, 1),
angle: g.f_or(52, 0.0),
scale: g.f_or(41, 1.0),
double: g.i_or(77, 0) != 0,
elevation: g.f_or(30, 0.0),
loops,
pattern_lines: pattern_lines(tags),
seeds,
gradient: gradient(tags),
pattern,
})
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Text in DXF: the caret encoding of control characters in strings, and MTEXT with its columns.
//!
//! Control characters are written as a caret and a letter (`^I` a tab, `^J` a line feed, `^ ` a
//! caret). TEXT, ATTDEF, ATTRIB and DIMENSION strings use the same encoding as MTEXT contents,
//! without line feeds: AutoCAD reads a bare `^2` in `10^2` as a control character.
//!
//! MTEXT columns are groups 75 (type), 76 (count), 78 (flow reversed), 79 (auto height), 48
//! (width), 49 (gutter) and 50 (column heights, after the column type; before it, 50 is the
//! rotation) before R2018, and an embedded object (group 101) from R2018 on.
use super::{Groups, Point, outside_app};
use crate::Tag;
/// Decode MTEXT contents: `^I` (tab), `^J` (line feed) and `^ ` (caret). Other carets stay as
/// they are (older files write stacked tolerances `\S+0.1^-0.1;` with a bare caret).
pub fn decode_contents(s: &str) -> String {
decode(s, true)
}
/// A single-line string as read: [`decode_contents`] without line feeds (`^J` stays as it is).
pub fn decode_line(s: &str) -> String {
decode(s, false)
}
fn decode(s: &str, line_feeds: bool) -> String {
if !s.contains('^') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c != '^' {
out.push(c);
continue;
}
match it.peek().copied() {
Some(' ') => out.push('^'),
Some('I') => out.push('\t'),
Some('J') if line_feeds => out.push('\n'),
_ => {
out.push('^');
continue;
}
}
it.next();
}
out
}
/// A single-line string (TEXT, ATTDEF/ATTRIB, DIMENSION) for a DXF file: a caret as `^ ` and a
/// tab as `^I`, read back by [`decode_line`]. It must hold no line break.
pub fn encode_line(s: &str) -> String {
s.replace('^', "^ ").replace('\t', "^I")
}
/// Encode MTEXT contents: a tab as `^I`, a line feed as `^J`, a caret as `^ `; other control
/// characters (which draw nothing) are dropped.
pub fn encode_contents(s: &str) -> String {
if !s.chars().any(|c| c == '^' || c.is_control()) {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 8);
for c in s.chars() {
match c {
'^' => out.push_str("^ "),
'\t' => out.push_str("^I"),
'\n' => out.push_str("^J"),
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
/// Apply `f` ([`encode_line`] or [`decode_line`]) to the groups of a `kind` record that hold an
/// encoded string: the text (1), and an ATTDEF's prompt (3). An embedded MTEXT (from group 101)
/// has its own and is left alone.
pub fn map_text_groups(kind: &str, tags: &mut [Tag], f: fn(&str) -> String) {
let kind = kind.to_ascii_uppercase();
if !matches!(kind.as_str(), "TEXT" | "ATTRIB" | "ATTDEF" | "DIMENSION") {
return;
}
for t in tags.iter_mut().take_while(|t| t.code != 101) {
if t.code == 1 || (t.code == 3 && kind == "ATTDEF") {
*t = Tag::s(t.code, f(&t.str()));
}
}
}
/// A record's groups split at its embedded object (`101 Embedded Object`): its own groups and,
/// when it has one, the object's.
pub fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) {
match tags.iter().position(|t| t.code == 101) {
Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)),
None => (tags, None),
}
}
/// Upper bound on the column heights read from one MTEXT.
pub const MAX_COLUMNS: usize = 1_000;
/// The columns of an MTEXT.
#[derive(Clone, Debug, PartialEq)]
pub struct MTextColumns {
/// Dynamic columns (type 2): the text flows into as many as it needs. Otherwise static
/// (type 1).
pub dynamic: bool,
pub count: u16,
/// One column's width (the MTEXT's own width is the total in some writers' files).
pub width: f64,
pub gutter: f64,
/// The column height: the tallest for manual heights.
pub height: f64,
pub auto_height: bool,
/// Each column's height, for dynamic columns with manual heights; else empty.
pub heights: Vec<f64>,
pub flow_reversed: bool,
}
fn pos(v: f64) -> f64 {
v.max(0.0)
}
impl MTextColumns {
/// The columns of an MTEXT record, from the legacy groups or the embedded object.
pub fn read(tags: &[Tag]) -> Option<MTextColumns> {
let (own, embedded) = split_embedded(tags);
// An application-defined group in the record may hold any code, 50 included.
let own_tags: Vec<&Tag> = outside_app(own).map(|(_, t)| t).collect();
let Some(start) = own_tags.iter().position(|t| t.code == 75) else { return Self::read_embedded(own, embedded?) };
let dynamic = match own_tags.get(start).map(|t| t.i64()) {
Some(1) => false,
Some(2) => true,
_ => return None,
};
let cols = own_tags.get(start..).unwrap_or_default();
let first = |code: i32| cols.iter().find(|t| t.code == code).copied();
// The first 50 is the number of heights that follow, unless the file has the heights
// of group 76 alone.
let fifties = cols.iter().filter(|t| t.code == 50);
let counted = first(76).is_some_and(|t| i64::try_from(fifties.clone().count()).is_ok_and(|n| n - 1 == t.i64()));
let heights: Vec<f64> = fifties.skip(usize::from(counted)).take(MAX_COLUMNS).map(|t| pos(t.f64())).collect();
let count = first(76).map_or(heights.len().max(1), |t| usize::try_from(t.i64()).unwrap_or(1)).clamp(1, MAX_COLUMNS);
let auto_height = first(79).is_some_and(|t| t.i64() != 0);
let defined = own_tags.iter().find(|t| t.code == 46).map_or(0.0, |t| pos(t.f64()));
let manual = dynamic && !auto_height;
let height =
if manual { heights.iter().copied().fold(0.0, f64::max) } else { heights.first().copied().filter(|h| *h > 0.0).unwrap_or(defined) };
Some(MTextColumns {
dynamic,
count: u16::try_from(count).unwrap_or(1),
width: first(48).map_or(0.0, |t| pos(t.f64())),
gutter: first(49).map_or(0.0, |t| pos(t.f64())),
height,
auto_height,
heights: if manual { heights } else { Vec::new() },
flow_reversed: first(78).is_some_and(|t| t.i64() != 0),
})
}
/// The columns of an R2018 MTEXT, from its embedded object, whose groups reuse MTEXT's
/// codes: 71 type, 72 count, 44 column width, 45 gutter, 73 auto height, 74 flow reversed,
/// 46 heights.
#[expect(clippy::cast_possible_truncation, reason = "the rounded count is clamped to 1..=MAX_COLUMNS before use")]
fn read_embedded(own: &[Tag], obj: &[Tag]) -> Option<MTextColumns> {
let first = |code: i32| obj.iter().find(|t| t.code == code);
let dynamic = match first(71).map(Tag::i64) {
Some(1) => false,
Some(2) => true,
_ => return None,
};
let heights: Vec<f64> = obj.iter().filter(|t| t.code == 46).take(MAX_COLUMNS).map(|t| pos(t.f64())).collect();
let auto_height = first(73).is_some_and(|t| t.i64() != 0);
let manual = dynamic && !auto_height;
// The column height: the entity's own 46 (defined height), else the object's 41 or first 46.
let own_46 = own.iter().find(|t| t.code == 46).map(|t| pos(t.f64()));
let defined = [own_46, first(41).map(Tag::f64), heights.first().copied()].into_iter().flatten().find(|h| *h > 0.0).unwrap_or(0.0);
// A dynamic auto-height object stores 0 columns in 72: the count follows from the total
// width (42 = n * 44 + (n - 1) * 45).
let [width, gutter, total] = [44, 45, 42].map(|g| first(g).map_or(0.0, Tag::f64));
let count = match first(72).map(Tag::i64) {
Some(n) if n > 0 => n,
Some(_) if width + gutter > 0.0 && total > 0.0 => ((total + gutter) / (width + gutter)).round().clamp(1.0, MAX_COLUMNS as f64) as i64,
_ => 1,
};
Some(MTextColumns {
dynamic,
count: u16::try_from(count.clamp(1, MAX_COLUMNS as i64)).unwrap_or(1),
width: pos(width),
gutter: pos(gutter),
height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { defined },
auto_height,
heights: if manual { heights } else { Vec::new() },
flow_reversed: first(74).is_some_and(|t| t.i64() != 0),
})
}
}
/// Multiline text: an MTEXT entity, or the one embedded in a multiline attribute.
#[derive(Clone, Debug, PartialEq)]
pub struct MText {
/// Group 10.
pub insert: Point,
/// Group 40: the text height.
pub height: f64,
/// Group 41: the reference rectangle's width (0: none). With columns, see
/// [`MTextColumns::width`].
pub width: f64,
/// Group 71: 1–9, top left to bottom right.
pub attach: u8,
/// Group 72: 1 left to right, 3 top to bottom, 5 by style.
pub drawing_direction: i64,
/// Groups 11/21/31: the X axis direction, which wins over the rotation when given and not
/// zero.
pub direction: Option<Point>,
/// Group 50, in degrees.
pub rotation: f64,
/// Group 7 (`Standard` when absent).
pub style: String,
/// The contents (the group 3 chunks, then group 1), decoded ([`decode_contents`]); the
/// formatting codes (`\P`, `\f...;`) are left as written.
pub contents: String,
/// Group 44: the line spacing factor (1 when absent).
pub line_spacing: f64,
/// Group 73 is 2: the spacing is exact, not "at least".
pub line_spacing_exact: bool,
/// Group 90: the background fill flags (1 a fill colour, 2 the window's colour, 16 a
/// frame), with the fill colour (63, 421) and the border offset factor (45).
pub background: Option<(i64, super::Color, f64)>,
pub columns: Option<MTextColumns>,
}
impl MText {
/// The typed form of MTEXT groups.
pub fn read(tags: &[Tag]) -> MText {
let columns = MTextColumns::read(tags);
// The MTEXT's own groups: an embedded object reuses their codes (44 is its column
// width, not the line spacing factor).
let (own, _) = split_embedded(tags);
let g = Groups(own);
let mut contents = String::new();
for t in own.iter().filter(|t| t.code == 3) {
contents.push_str(&t.str());
}
contents.push_str(&g.s(1).unwrap_or_default());
// The direction and rotation come from before the column groups, where 50 is a height.
let head = Groups(own.get(..own.iter().position(|t| t.code == 75).unwrap_or(own.len())).unwrap_or(own));
let background = g.i(90).filter(|f| *f != 0).map(|f| {
let color = match (g.i(421), g.i(63)) {
(Some(tc), _) => super::Color::from_true(tc),
(None, Some(v)) => super::Color::from_index(v),
(None, None) => super::Color::ByLayer,
};
(f, color, g.f_or(45, 1.5))
});
MText {
insert: g.point(10),
height: g.f_or(40, 0.0),
width: pos(g.f_or(41, 0.0)),
attach: u8::try_from(g.i_or(71, 1).clamp(1, 9)).unwrap_or(1),
drawing_direction: g.i_or(72, 1),
direction: head.point_opt(11).filter(|d| d[0] != 0.0 || d[1] != 0.0 || d[2] != 0.0),
rotation: head.f_or(50, 0.0),
style: g.name(7).unwrap_or_else(|| "Standard".into()),
contents: decode_contents(&contents),
line_spacing: g.f_or(44, 1.0),
line_spacing_exact: g.i(73) == Some(2),
background,
columns,
}
}
/// The angle of the text's X axis in radians: of the direction when the record has one,
/// else the rotation.
pub fn angle(&self) -> f64 {
match self.direction {
Some(d) if d[0] != 0.0 || d[1] != 0.0 => d[1].atan2(d[0]),
_ => self.rotation.to_radians(),
}
}
}
+393
View File
@@ -0,0 +1,393 @@
//! The drawing model's entities: common properties, geometry, text, hatches, dimensions.
use craft_dxf::model::text::{MTextColumns, decode_contents, decode_line, encode_contents, encode_line, map_text_groups};
use craft_dxf::model::{Color, Document, Entity, EntityKind, HatchEdge, HatchPath, Lineweight, Transparency, entity_records, records};
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")
}
/// The entities of `text`, the records of an ENTITIES section.
fn entities(text: &str) -> Vec<Entity> {
let t = tags(text);
let (recs, strays) = entity_records(&records(&t));
assert_eq!(strays, 0);
recs.iter().map(Entity::read).collect()
}
fn one(text: &str) -> Entity {
let mut e = entities(text);
assert_eq!(e.len(), 1);
e.remove(0)
}
#[test]
fn common_properties() {
let c = one("0|LINE|5|2A|330|1F|100|AcDbEntity|8|Walls|6|DASHED|62|1|420|65280|370|50|48|2.5|60|1|67|1|440|33554687|390|B|100|AcDbLine|39|1.5|210|0.0|220|0.0|230|-1.0").common;
assert_eq!((c.handle, c.owner, c.layer.as_str(), c.linetype.as_deref()), (Some(0x2A), Some(0x1F), "Walls", Some("DASHED")));
assert_eq!((c.color, c.lineweight, c.ltscale), (Color::Rgb([0, 255, 0]), Lineweight::Hundredths(50), 2.5));
assert!(!c.visible && c.paper_space);
assert_eq!((c.transparency, c.thickness, c.extrusion, c.plot_style), (Transparency::Alpha(255), 1.5, [0.0, 0.0, -1.0], Some(0xB)));
let d = one("0|LINE").common;
assert_eq!((d.handle, d.owner, d.layer.as_str(), d.linetype.as_deref(), d.color), (None, None, "0", None, Color::ByLayer));
assert_eq!((d.lineweight, d.ltscale, d.visible, d.paper_space, d.extrusion), (Lineweight::ByLayer, 1.0, true, false, [0.0, 0.0, 1.0]));
assert_eq!(one("0|LINE|6|BYLAYER|48|-1.0|62|-2").common, d, "ByLayer by name, a negative scale and a negative colour are the defaults");
}
#[test]
fn entity_properties_are_not_read_from_the_entitys_own_subclass() {
// An MTEXT's 48 is its column width, not the linetype scale, and a marker in an
// application's braces is the application's: it does not end the entity's groups. A record with no AcDbEntity marker has its
// layer wherever it is.
let c = one("0|MTEXT|102|{APP|100|AcDbMText|102|}|100|AcDbEntity|8|A|100|AcDbMText|48|7.0|62|3").common;
assert_eq!((c.ltscale, c.color, c.layer.as_str()), (1.0, Color::ByLayer, "A"));
let c = one("0|LINE|100|AcDbLine|8|B|62|3").common;
assert_eq!((c.color, c.layer.as_str()), (Color::Index(3), "B"));
// An embedded object ends the entity's groups.
assert_eq!(one("0|ATTDEF|8|C|101|Embedded Object|62|5").common.color, Color::ByLayer);
}
#[test]
fn curves() {
let k = |text: &str| one(text).kind;
assert_eq!(k("0|LINE|10|1.0|20|2.0|30|3.0|11|4.0|21|5.0"), EntityKind::Line { start: [1.0, 2.0, 3.0], end: [4.0, 5.0, 0.0] });
assert_eq!(k("0|POINT|10|1.0|50|30.0"), EntityKind::Point { location: [1.0, 0.0, 0.0], angle: 30.0 });
assert_eq!(k("0|CIRCLE|10|1.0|20|2.0|40|3.0"), EntityKind::Circle { center: [1.0, 2.0, 0.0], radius: 3.0 });
assert_eq!(k("0|ARC|10|1.0|40|3.0|50|10.0"), EntityKind::Arc { center: [1.0, 0.0, 0.0], radius: 3.0, start_angle: 10.0, end_angle: 360.0 });
assert_eq!(
k("0|ELLIPSE|10|1.0|11|2.0|21|0.0|40|0.5|41|0.0|42|3.0"),
EntityKind::Ellipse { center: [1.0, 0.0, 0.0], major: [2.0, 0.0, 0.0], ratio: 0.5, start: 0.0, end: 3.0 }
);
assert_eq!(k("0|RAY|10|1.0|11|0.0|21|1.0"), EntityKind::Ray { base: [1.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0] });
assert_eq!(k("0|XLINE|10|1.0|11|0.0|21|1.0"), EntityKind::XLine { base: [1.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0] });
let EntityKind::Spline(s) = k(
"0|SPLINE|70|8|71|2|40|0.0|40|0.0|40|1.0|10|0.0|20|0.0|10|1.0|20|1.0|30|5.0|10|2.0|41|1.0|41|2.0|41|1.0|11|9.0|21|9.0|12|1.0|22|0.0|44|0.01",
) else {
panic!("a spline")
};
assert_eq!((s.degree, s.flags, s.knots.len(), s.weights.as_slice(), s.fit_tolerance), (2, 8, 3, [1.0, 2.0, 1.0].as_slice(), 0.01));
assert_eq!(s.control, [[0.0, 0.0, 0.0], [1.0, 1.0, 5.0], [2.0, 0.0, 0.0]], "the third point has no y or z of its own");
assert_eq!((s.fit.as_slice(), s.start_tangent, s.end_tangent), ([[9.0, 9.0, 0.0]].as_slice(), Some([1.0, 0.0, 0.0]), None));
}
#[test]
fn polylines() {
let EntityKind::LwPolyline(p) = one("0|LWPOLYLINE|90|3|70|129|43|0.5|38|2.0|10|0.0|20|0.0|42|1.0|10|4.0|20|0.0|40|0.1|41|0.2|10|4.0|20|3.0").kind
else {
panic!("a lightweight polyline")
};
assert!(p.closed && p.plinegen);
assert_eq!((p.const_width, p.elevation, p.vertices.len()), (0.5, 2.0, 3));
assert_eq!((p.vertices[0].bulge, p.vertices[1].start_width, p.vertices[1].end_width, p.vertices[2].y), (1.0, 0.1, 0.2, 3.0));
// A polyface mesh: three vertices and a face record that names them.
let e = one(
"0|POLYLINE|66|1|70|64|71|3|72|1|10|0.0|20|0.0|30|7.0|0|VERTEX|10|0.0|20|0.0|30|0.0|70|192|0|VERTEX|10|1.0|70|192|0|VERTEX|10|1.0|20|1.0|70|192|0|VERTEX|70|128|71|1|72|2|73|-3|0|SEQEND",
);
let EntityKind::Polyline(m) = e.kind else { panic!("a polyline") };
assert!(m.is_polyface() && !m.is_mesh() && !m.is_3d() && !m.closed());
assert_eq!((m.m, m.n, m.elevation, m.vertices.len()), (3, 1, 7.0, 4));
assert!(m.vertices[3].is_face() && !m.vertices[0].is_face());
assert_eq!(m.vertices[3].face, [1, 2, -3, 0]);
let EntityKind::Polyline(h) = one("0|POLYLINE|70|4|40|0.5|41|0.25|0|VERTEX|10|1.0|42|0.5|40|0.1|41|0.2|70|16|0|SEQEND").kind else {
panic!("a polyline")
};
assert_eq!((h.start_width, h.end_width), (0.5, 0.25));
let v = h.vertices[0];
assert!(v.is_spline_frame());
assert_eq!((v.bulge, v.start_width, v.end_width), (0.5, 0.1, 0.2));
}
#[test]
fn text_and_attributes() {
let EntityKind::Text(t) = one("0|TEXT|10|1.0|20|2.0|11|3.0|21|4.0|40|2.5|1|10^ 2^Im|50|90.0|41|0.8|51|15.0|7|Notes|71|2|72|1|73|2").kind else {
panic!("text")
};
assert_eq!((t.insert, t.align, t.height, t.value.as_str()), ([1.0, 2.0, 0.0], Some([3.0, 4.0, 0.0]), 2.5, "10^2\tm"));
assert_eq!((t.rotation, t.width_factor, t.oblique, t.style.as_str(), t.generation, t.halign, t.valign), (90.0, 0.8, 15.0, "Notes", 2, 1, 2));
let EntityKind::Text(d) = one("0|TEXT|1|x").kind else { panic!("text") };
assert_eq!((d.align, d.width_factor, d.style.as_str(), d.halign, d.valign), (None, 1.0, "Standard", 0, 0));
let EntityKind::AttDef(a) =
one("0|ATTDEF|100|AcDbText|10|1.0|1|default|100|AcDbAttributeDefinition|280|0|3|Enter^Ia|2|TAG|70|12|74|3|280|1").kind
else {
panic!("an attribute definition")
};
assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.flags, a.text.valign, a.lock_position), ("TAG", "Enter\ta", 12, 3, true));
assert!(a.multiline.is_none());
// A block reference with two attributes; the second is multiline.
let EntityKind::Insert(i) = one("0|INSERT|66|1|2|DOOR|10|1.0|20|2.0|41|2.0|43|-1.0|50|45.0|70|3|71|0|44|5.0|45|6.0
0|ATTRIB|8|Tags|62|2|1|one|2|A|70|1|280|0
0|ATTRIB|1|~|2|B|101|Embedded Object|10|7.0|40|2.0|71|5|3|line one^J|1|line two
0|SEQEND")
.kind
else {
panic!("an insert")
};
assert_eq!((i.block.as_str(), i.insert, i.scale, i.rotation), ("DOOR", [1.0, 2.0, 0.0], [2.0, 1.0, -1.0], 45.0));
assert_eq!((i.columns, i.rows, i.column_spacing, i.row_spacing), (3, 1, 5.0, 6.0));
assert_eq!(i.attribs.len(), 2);
let (c, a) = &i.attribs[0];
assert_eq!(
(c.layer.as_str(), c.color, a.tag.as_str(), a.text.value.as_str(), a.flags, a.lock_position),
("Tags", Color::Index(2), "A", "one", 1, false)
);
assert!(a.prompt.is_empty(), "an ATTRIB has no prompt");
let m = i.attribs[1].1.multiline.as_ref().expect("embedded MTEXT");
assert_eq!((m.contents.as_str(), m.insert, m.height, m.attach), ("line one\nline two", [7.0, 0.0, 0.0], 2.0, 5));
}
#[test]
fn caret_encoding() {
assert_eq!(decode_contents("a^Ib^Jc^ d^2^"), "a\tb\nc^d^2^");
assert_eq!(decode_line("a^Ib^Jc^ d"), "a\tb^Jc^d", "a single line has no line feed");
assert_eq!(encode_contents("a\tb\nc^d\u{7}"), "a^Ib^Jc^ d", "other control characters are dropped");
assert_eq!(encode_line("10^2\t"), "10^ 2^I");
for s in ["", "plain", "10^2", "tab\there", "^^ ^I"] {
assert_eq!(decode_line(&encode_line(s)), s);
assert_eq!(decode_contents(&encode_contents(s)), s);
}
let mut t = vec![Tag::s(1, "a^ b"), Tag::s(3, "p^ q"), Tag::i(101, 0), Tag::s(1, "e^ f")];
map_text_groups("attdef", &mut t, decode_line);
assert_eq!(t.iter().map(Tag::str).collect::<Vec<_>>(), ["a^b", "p^q", "0", "e^ f"], "the embedded object's text is left alone");
let mut t = vec![Tag::s(1, "a^ b"), Tag::s(3, "p^ q")];
map_text_groups("TEXT", &mut t, decode_line);
assert_eq!(t[1].str(), "p^ q", "only an ATTDEF's 3 is a prompt");
map_text_groups("LINE", &mut t, |_| String::new());
assert_eq!(t[0].str(), "a^b");
}
#[test]
fn mtext() {
let EntityKind::MText(m) = one("0|MTEXT|10|1.0|20|2.0|40|2.5|41|40.0|71|5|72|1|3|first ^Ipart, |3|second, |1|last\\Pline|7|Notes|11|0.0|21|1.0|50|30.0|44|1.2|73|2|90|1|63|4|45|1.25").kind else {
panic!("multiline text")
};
assert_eq!((m.insert, m.height, m.width, m.attach, m.style.as_str()), ([1.0, 2.0, 0.0], 2.5, 40.0, 5, "Notes"));
assert_eq!(m.contents, "first \tpart,second,last\\Pline");
assert_eq!((m.direction, m.rotation, m.line_spacing, m.line_spacing_exact), (Some([0.0, 1.0, 0.0]), 30.0, 1.2, true));
assert!((m.angle() - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "the direction wins over the rotation");
assert_eq!(m.background, Some((1, Color::Index(4), 1.25)));
assert!(m.columns.is_none());
let EntityKind::MText(r) = one("0|MTEXT|1|x|50|90.0|11|0.0|21|0.0|71|99").kind else { panic!("multiline text") };
assert!((r.angle() - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "a zero direction is none");
assert_eq!((r.attach, r.background, r.line_spacing), (9, None, 1.0));
}
#[test]
fn mtext_columns() {
// Legacy groups: static, 2 columns, the first 50 after 75 counts the heights; the 50 before
// 75 is the rotation.
let t = tags("0|MTEXT|10|0.0|50|15.0|1|x|75|1|76|2|78|1|79|0|48|30.0|49|5.0|50|2|50|80.0|50|80.0");
let c = MTextColumns::read(&t[1..]).expect("columns");
assert_eq!((c.dynamic, c.count, c.width, c.gutter, c.height, c.auto_height, c.flow_reversed), (false, 2, 30.0, 5.0, 80.0, false, true));
assert!(c.heights.is_empty(), "only manual dynamic columns keep each height");
let EntityKind::MText(m) = one("0|MTEXT|10|0.0|50|15.0|1|x|75|1|76|2|48|30.0|49|5.0|50|2|50|80.0|50|80.0").kind else { panic!("multiline text") };
assert_eq!(m.rotation, 15.0);
// Dynamic with manual heights, and no count before them.
let t = tags("0|MTEXT|1|x|75|2|76|3|79|0|48|30.0|49|5.0|50|10.0|50|40.0|50|20.0");
let c = MTextColumns::read(&t[1..]).expect("columns");
assert_eq!((c.dynamic, c.count, c.height, c.heights.as_slice()), (true, 3, 40.0, [10.0, 40.0, 20.0].as_slice()));
// R2018: the embedded object; 72 is 0 for dynamic auto-height, the count follows the widths.
let t = tags("0|MTEXT|1|x|46|50.0|101|Embedded Object|70|1|10|1.0|11|0.0|40|0.0|41|0.0|42|65.0|43|0.0|71|2|72|0|44|30.0|45|5.0|73|1|74|0");
let c = MTextColumns::read(&t[1..]).expect("columns");
assert_eq!((c.dynamic, c.count, c.width, c.gutter, c.height, c.auto_height), (true, 2, 30.0, 5.0, 50.0, true));
// No columns: type 0, or no column groups at all.
assert!(MTextColumns::read(&tags("0|MTEXT|1|x|75|0|76|0")[1..]).is_none());
assert!(MTextColumns::read(&tags("0|MTEXT|1|x|101|Embedded Object|71|0")[1..]).is_none());
}
#[test]
fn the_column_height_limit() {
let mut text = String::from("0|MTEXT|1|x|75|2|76|5000|79|0");
for _ in 0..5000 {
text.push_str("|50|1.0");
}
let t = tags(&text);
let c = MTextColumns::read(&t[1..]).expect("columns");
assert_eq!((c.heights.len(), c.count), (1000, 1000));
let mut text = String::from("0|MTEXT|1|x|101|Embedded Object|71|2|72|40000|73|0");
for _ in 0..5000 {
text.push_str("|46|1.0");
}
let t = tags(&text);
let c = MTextColumns::read(&t[1..]).expect("columns");
assert_eq!((c.heights.len(), c.count), (1000, 1000));
// A total width that asks for a count beyond any number.
let t = tags("0|MTEXT|1|x|101|Embedded Object|71|2|72|0|73|1|42|1e300|44|1e-300|45|0.0");
assert_eq!(MTextColumns::read(&t[1..]).expect("columns").count, 1000);
}
#[test]
fn dimensions_and_leaders() {
let EntityKind::Dimension(d) =
one("0|DIMENSION|2|*D1|3|ISO-25|10|1.0|20|2.0|11|3.0|21|4.0|12|0.5|13|5.0|14|6.0|15|7.0|16|8.0|70|161|1|<> ^Im|42|12.5|50|30.0|52|60.0|53|45.0|40|2.0").kind
else {
panic!("a dimension")
};
assert_eq!((d.kind(), d.flags & 128, d.flags & 32), (1, 128, 32));
assert_eq!((d.block.as_deref(), d.style.as_str(), d.text.as_str(), d.measurement), (Some("*D1"), "ISO-25", "<> \tm", Some(12.5)));
assert_eq!((d.defpt, d.text_mid, d.insertion[0]), ([1.0, 2.0, 0.0], [3.0, 4.0, 0.0], 0.5));
assert_eq!((d.p13[0], d.p14[0], d.p15[0], d.p16[0]), (5.0, 6.0, 7.0, 8.0));
assert_eq!((d.rotation, d.oblique, d.text_rotation, d.leader_length), (30.0, 60.0, 45.0, 2.0));
let EntityKind::Dimension(n) = one("0|DIMENSION|70|6").kind else { panic!("a dimension") };
assert_eq!((n.block.as_deref(), n.style.as_str(), n.measurement, n.kind()), (None, "Standard", None, 6));
// The second vertex has no z: it does not take the third's.
let EntityKind::Leader(l) = one("0|LEADER|3|Arrows|71|0|72|1|73|0|340|4F|10|1.0|20|2.0|30|3.0|10|4.0|20|5.0|10|6.0|20|7.0|30|8.0").kind else {
panic!("a leader")
};
assert_eq!(l.vertices, [[1.0, 2.0, 3.0], [4.0, 5.0, 0.0], [6.0, 7.0, 8.0]]);
assert!(!l.arrow && l.spline);
assert_eq!((l.style.as_str(), l.annotation_type, l.annotation), ("Arrows", 0, Some(0x4F)));
}
#[test]
fn faces_viewports_images_and_tolerances() {
let k = |text: &str| one(text).kind;
let three = "|10|0.0|11|1.0|12|1.0|22|1.0";
assert_eq!(k(&format!("0|SOLID{three}")), EntityKind::Solid { corners: [[0.0; 3], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [1.0, 1.0, 0.0]] });
assert!(matches!(k(&format!("0|TRACE{three}|13|9.0")), EntityKind::Trace { corners } if corners[3] == [9.0, 0.0, 0.0]));
assert!(matches!(k(&format!("0|3DFACE{three}|70|5")), EntityKind::Face3d { hidden_edges: 5, .. }));
let EntityKind::Viewport(v) =
k("0|VIEWPORT|10|5.0|20|4.0|40|8.0|41|6.0|69|2|90|81920|12|1.0|22|2.0|17|9.0|45|30.0|51|90.0|340|A1|331|B1|331|B2|341|B3")
else {
panic!("a viewport")
};
assert_eq!((v.center, v.width, v.height, v.id, v.status & 16384, v.status & 131_072), ([5.0, 4.0, 0.0], 8.0, 6.0, 2, 16384, 0));
assert_eq!((v.view_center, v.view_target, v.view_direction, v.view_height, v.twist), ([1.0, 2.0], [9.0, 0.0, 0.0], [0.0, 0.0, 1.0], 30.0, 90.0));
assert_eq!((v.clip, v.frozen_layers.as_slice()), (Some(0xA1), [0xB1, 0xB2, 0xB3].as_slice()));
let EntityKind::Image(i) =
k("0|IMAGE|10|1.0|11|0.5|12|0.0|22|0.5|13|640.0|23|480.0|340|C1|70|7|280|1|281|60|71|2|14|0.0|24|0.0|14|10.0|24|0.0|14|10.0|24|10.0|290|1")
else {
panic!("an image")
};
assert_eq!((i.insert[0], i.u[0], i.v[1], i.size, i.image_def), (1.0, 0.5, 0.5, [640.0, 480.0], Some(0xC1)));
assert_eq!((i.display, i.clipping, i.brightness, i.contrast, i.fade, i.clip_type, i.clip_inverted), (7, true, 60, 50, 0, 2, true));
assert_eq!(i.clip, [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]);
assert!(matches!(k("0|WIPEOUT|10|1.0|14|-0.5|24|-0.5|14|0.5|24|0.5"), EntityKind::Wipeout(w) if w.clip.len() == 2 && w.clip_type == 1));
assert_eq!(
k("0|TOLERANCE|3|Tol|10|1.0|1|{\\Fgdt;j}%%v0.1"),
EntityKind::Tolerance { insert: [1.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0], style: "Tol".into(), text: "{\\Fgdt;j}%%v0.1".into() }
);
assert_eq!(k("0|MULTILEADER|10|1.0"), EntityKind::Other, "a type the model does not have is read from its tags");
}
#[test]
fn hatch_with_a_polyline_loop_and_a_pattern() {
let EntityKind::Hatch(h) = one("0|HATCH|10|0.0|20|0.0|30|2.5|2|ANSI31|70|0|71|1|91|1
92|7|72|1|73|1|93|3|10|0.0|20|0.0|42|0.5|10|4.0|20|0.0|42|0.0|10|4.0|20|3.0|42|0.0|97|2|330|A1|330|A2
75|1|76|1|52|45.0|41|2.0|77|0|78|2
53|45.0|43|0.0|44|0.0|45|-1.4|46|1.4|79|0
53|135.0|43|1.0|44|2.0|45|1.0|46|1.0|79|2|49|3.0|49|-1.5
98|2|10|1.0|20|1.0|10|2.0|20|2.0")
.kind
else {
panic!("a hatch")
};
assert_eq!((h.pattern.as_str(), h.solid, h.associative, h.style, h.pattern_type), ("ANSI31", false, true, 1, 1));
assert_eq!((h.angle, h.scale, h.double, h.elevation), (45.0, 2.0, false, 2.5));
assert_eq!(h.loops.len(), 1);
let l = &h.loops[0];
assert_eq!((l.flags, l.sources.as_slice()), (7, [0xA1, 0xA2].as_slice()));
assert_eq!(l.path, HatchPath::Polyline { closed: true, vertices: vec![[0.0, 0.0, 0.5], [4.0, 0.0, 0.0], [4.0, 3.0, 0.0]] });
assert_eq!(h.pattern_lines.len(), 2);
assert_eq!((h.pattern_lines[0].angle, h.pattern_lines[0].offset, h.pattern_lines[0].dashes.len()), (45.0, [-1.4, 1.4], 0));
assert_eq!((h.pattern_lines[1].base, h.pattern_lines[1].dashes.as_slice()), ([1.0, 2.0], [3.0, -1.5].as_slice()));
assert_eq!(h.seeds, [[1.0, 1.0], [2.0, 2.0]]);
assert!(h.gradient.is_none());
}
#[test]
fn hatch_with_edge_loops_and_a_gradient() {
let EntityKind::Hatch(h) = one(
"0|HATCH|2|SOLID|70|1|71|0|91|2
92|1|93|4
72|1|10|0.0|20|0.0|11|4.0|21|0.0
72|2|10|4.0|20|1.0|40|1.0|50|270.0|51|360.0|73|1
72|3|10|2.0|20|2.0|11|2.0|21|0.0|40|0.5|50|0.0|51|180.0|73|0
72|4|94|3|73|1|74|0|95|8|96|4|40|0.0|40|0.0|40|0.0|40|0.0|40|1.0|40|1.0|40|1.0|40|1.0|10|0.0|20|2.0|42|1.0|10|0.0|20|1.5|42|2.0|10|0.0|20|0.5|42|2.0|10|0.0|20|0.0|42|1.0|97|2|11|0.0|21|2.0|11|0.0|21|0.0|12|0.0|22|-1.0|13|0.0|23|-1.0
97|0
92|16|93|1
72|2|10|2.0|20|1.0|40|0.25|50|0.0|51|360.0|73|1
97|1|330|B1
75|0|76|1|98|0
450|1|451|0|460|0.785|461|0.0|452|0|462|0.0|453|2|463|0.0|63|5|421|255|463|1.0|63|2|470|LINEAR
1001|ACAD|1010|0.0",
)
.kind
else {
panic!("a hatch")
};
assert!(h.solid && !h.associative);
assert_eq!(h.loops.len(), 2);
let HatchPath::Edges(e) = &h.loops[0].path else { panic!("edges") };
assert_eq!(e.len(), 4);
assert_eq!(e[0], HatchEdge::Line { start: [0.0, 0.0], end: [4.0, 0.0] });
assert_eq!(e[1], HatchEdge::Arc { center: [4.0, 1.0], radius: 1.0, start_angle: 270.0, end_angle: 360.0, ccw: true });
assert_eq!(e[2], 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 });
let HatchEdge::Spline { degree, rational, periodic, knots, control, weights, fit, start_tangent, end_tangent } = &e[3] else {
panic!("a spline edge")
};
assert_eq!((*degree, *rational, *periodic, knots.len(), control.len()), (3, true, false, 8, 4));
assert_eq!(
(weights.as_slice(), fit.len(), *start_tangent, *end_tangent),
([1.0, 2.0, 2.0, 1.0].as_slice(), 2, Some([0.0, -1.0]), Some([0.0, -1.0]))
);
// The spline's own 97 (fit points) is not the loop's source count.
assert!(h.loops[0].sources.is_empty());
assert_eq!((h.loops[1].flags, h.loops[1].sources.as_slice()), (16, [0xB1].as_slice()));
let g = h.gradient.expect("a gradient");
assert_eq!((g.name.as_str(), g.angle, g.shift, g.single), ("LINEAR", 0.785, 0.0, false));
assert_eq!(g.colors, [Color::Rgb([0, 0, 255]), Color::Index(2)], "a true colour wins over its index");
}
#[test]
fn damaged_hatches() {
let k = |text: &str| one(text).kind;
assert_eq!(k("0|HATCH|2|SOLID"), EntityKind::Other, "no boundary data");
assert_eq!(k("0|HATCH|91|2|92|2|93|1|10|0.0|20|0.0"), EntityKind::Other, "the second loop is missing");
assert_eq!(k("0|HATCH|91|1|92|2|8|x|93|1"), EntityKind::Other, "a foreign group in the loop header");
// Counts larger than the data: what is there is read, nothing is reserved for the rest.
let EntityKind::Hatch(h) = k("0|HATCH|91|1|92|2|93|2000000000|10|1.0|20|2.0|97|2000000000|330|A|98|2000000000|10|5.0|20|6.0") else {
panic!("a hatch")
};
assert_eq!(h.loops[0].path, HatchPath::Polyline { closed: false, vertices: vec![[1.0, 2.0, 0.0]] });
assert_eq!((h.loops[0].sources.as_slice(), h.seeds.as_slice()), ([0xA].as_slice(), [[5.0, 6.0]].as_slice()));
let EntityKind::Hatch(h) = k("0|HATCH|91|1|92|0|93|9|72|9|10|1.0|72|1|10|1.0|20|1.0|11|2.0|21|2.0|72|4|95|-5|96|2000000000|10|1.0|20|1.0") else {
panic!("a hatch")
};
let HatchPath::Edges(e) = &h.loops[0].path else { panic!("edges") };
assert_eq!(e.len(), 2, "an edge type the format does not have is passed over");
assert!(matches!(&e[1], HatchEdge::Spline { knots, control, .. } if knots.is_empty() && control.len() == 1));
assert_eq!(k("0|HATCH|91|-4").clone(), k("0|HATCH|91|0"), "a negative loop count is none");
// A 450 that says no gradient.
assert!(matches!(k("0|HATCH|91|0|450|0|463|0.0|63|1"), EntityKind::Hatch(h) if h.gradient.is_none()));
}
#[test]
fn entities_of_a_whole_drawing() {
let t = tags(
"0|SECTION|2|BLOCKS|0|BLOCK|2|B|0|CIRCLE|40|2.0|0|ENDBLK|0|ENDSEC|0|SECTION|2|ENTITIES|0|INSERT|2|B|0|POLYLINE|0|VERTEX|10|1.0|0|SEQEND|0|ENDSEC|0|EOF",
);
let d = Document::read(&t);
let kinds: Vec<EntityKind> = d.entities.iter().chain(&d.blocks[0].entities).map(|e| Entity::read(e).kind).collect();
assert!(matches!(kinds.as_slice(), [EntityKind::Insert(_), EntityKind::Polyline(p), EntityKind::Circle { .. }] if p.vertices.len() == 1));
}
#[test]
fn a_spline_edge_without_fit_data_leaves_the_loops_source_count() {
// Before R2010 a spline edge has no 97 of its own: the one after it counts the loop's sources.
let EntityKind::Hatch(h) = one(
"0|HATCH|91|1|92|1|93|1|72|4|94|1|73|0|74|0|95|4|96|2|40|0.0|40|0.0|40|1.0|40|1.0|10|0.0|20|0.0|10|1.0|20|1.0|97|2|330|A1|330|A2|75|0|98|0",
)
.kind
else {
panic!("a hatch")
};
assert_eq!(h.loops[0].sources, [0xA1, 0xA2]);
assert!(
matches!(&h.loops[0].path, HatchPath::Edges(e) if matches!(&e[0], HatchEdge::Spline { fit, control, .. } if fit.is_empty() && control.len() == 2))
);
}