mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
craft-dxf: model, the drawing above the tags (header, tables, blocks, layouts) (#926)
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.
This commit is contained in:
1 parent
49f5f5e83d
commit
00c8771f0c
7 files changed
+1646
-5
No files matched your search
@@ -76,8 +76,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
(PostScript- or PDF-based) apart. No PostScript interpreter.
|
||||
- [`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. Bounded on hostile input. Mapping tags to a
|
||||
drawing stays in each app.
|
||||
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
|
||||
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
|
||||
project and its saved view as type parameters. The project model stays in EffectCraft.
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! A standalone DXF tag reader and writer.
|
||||
//!
|
||||
//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a
|
||||
//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping
|
||||
//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF
|
||||
//! Reference; depends on no other Craft crate. Moved from CADCraft's `cadcraft-dxf`.
|
||||
//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. [`model`]
|
||||
//! reads the tags as a drawing (header variables, tables, blocks, entities, objects); mapping
|
||||
//! that onto an application's own document is left to the caller. Written from Autodesk's
|
||||
//! published DXF Reference; depends on no other Craft crate. Moved from CADCraft's `cadcraft-dxf`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
pub mod model;
|
||||
mod version;
|
||||
pub use version::Version;
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! A drawing above the tag level: what a DXF file says, typed.
|
||||
//!
|
||||
//! [`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.
|
||||
//!
|
||||
//! 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
|
||||
//! group holds (degrees, except where a field says radians), names as written. Turning that into
|
||||
//! an application's document (resolving ByLayer, flattening, choosing fonts) is the caller's.
|
||||
//!
|
||||
//! Merged from the readers of two applications: CADCraft's (`cadcraft-io`, `dxf_read`) and
|
||||
//! VectorCraft's (`vectorcraft-cad`, `import`). Written from Autodesk's published DXF Reference.
|
||||
|
||||
use crate::Tag;
|
||||
|
||||
pub mod aci;
|
||||
mod document;
|
||||
mod tables;
|
||||
|
||||
pub use document::{Block, Dictionary, Document, EntityRecord, Header, Layout, Record, entity_records, records};
|
||||
pub use tables::{AppId, BlockRecord, DashElement, DimStyle, Layer, Linetype, TextStyle, Ucs, View, Vport, dim_var_code, dim_var_name};
|
||||
|
||||
/// A point or vector: x, y, z.
|
||||
pub type Point = [f64; 3];
|
||||
|
||||
/// A handle (the hexadecimal of groups 5, 105 and 320–369), or `None` for text that is not one.
|
||||
/// Handle 0 is "no object" and is returned as `Some(0)`.
|
||||
pub fn parse_handle(s: &str) -> Option<u64> {
|
||||
u64::from_str_radix(s.trim(), 16).ok()
|
||||
}
|
||||
|
||||
/// The groups of one record, read by code.
|
||||
///
|
||||
/// A missing number is `None` (or the default given); a value that is not a finite number reads
|
||||
/// as 0, as [`Tag::f64`] gives it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Groups<'a>(pub &'a [Tag]);
|
||||
|
||||
impl<'a> Groups<'a> {
|
||||
fn first(&self, code: i32) -> Option<&'a Tag> {
|
||||
self.0.iter().find(|t| t.code == code)
|
||||
}
|
||||
/// The first `code` as a number.
|
||||
pub fn f(&self, code: i32) -> Option<f64> {
|
||||
self.first(code).map(Tag::f64)
|
||||
}
|
||||
pub fn f_or(&self, code: i32, default: f64) -> f64 {
|
||||
self.f(code).unwrap_or(default)
|
||||
}
|
||||
/// The first `code` as a whole number.
|
||||
pub fn i(&self, code: i32) -> Option<i64> {
|
||||
self.first(code).map(Tag::i64)
|
||||
}
|
||||
pub fn i_or(&self, code: i32, default: i64) -> i64 {
|
||||
self.i(code).unwrap_or(default)
|
||||
}
|
||||
/// The first `code` as text, as written.
|
||||
pub fn s(&self, code: i32) -> Option<String> {
|
||||
self.first(code).map(Tag::str)
|
||||
}
|
||||
/// The first `code` as a name: trimmed, and `None` when empty.
|
||||
pub fn name(&self, code: i32) -> Option<String> {
|
||||
self.first(code).map(|t| t.str().trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
/// The first `code` as a handle.
|
||||
pub fn handle(&self, code: i32) -> Option<u64> {
|
||||
self.first(code).and_then(|t| parse_handle(&t.str()))
|
||||
}
|
||||
/// The point at `code` (x), `code + 10` (y) and `code + 20` (z); 0 for what is missing.
|
||||
pub fn point(&self, code: i32) -> Point {
|
||||
[self.f_or(code, 0.0), self.f_or(code + 10, 0.0), self.f_or(code + 20, 0.0)]
|
||||
}
|
||||
/// [`Groups::point`] when its x is given.
|
||||
pub fn point_opt(&self, code: i32) -> Option<Point> {
|
||||
self.f(code).map(|_| self.point(code))
|
||||
}
|
||||
/// Every `code` as a number, in order.
|
||||
pub fn all_f(&self, code: i32) -> Vec<f64> {
|
||||
self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect()
|
||||
}
|
||||
/// The points a record repeats: each `code` (x) starts one, and the `code + 10` (y) and
|
||||
/// `code + 20` (z) before the next x belong to it. A point that leaves its y or z out has 0
|
||||
/// there, and does not take the next point's.
|
||||
pub fn points(&self, code: i32) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = Vec::new();
|
||||
for t in self.0 {
|
||||
if t.code == code {
|
||||
out.push([t.f64(), 0.0, 0.0]);
|
||||
} else if let Some(p) = out.last_mut() {
|
||||
if t.code == code + 10 {
|
||||
p[1] = t.f64();
|
||||
} else if t.code == code + 20 {
|
||||
p[2] = t.f64();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The groups after the subclass marker `100 <marker>`, up to the next marker; empty when the
|
||||
/// record has no such marker (R12 files have none: read the whole record there).
|
||||
pub fn subclass(&self, marker: &str) -> Groups<'a> {
|
||||
let Some(at) = self.0.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == marker)) else {
|
||||
return Groups(&[]);
|
||||
};
|
||||
let rest = self.0.get(at + 1..).unwrap_or(&[]);
|
||||
let end = rest.iter().position(|t| t.code == 100).unwrap_or(rest.len());
|
||||
Groups(rest.get(..end).unwrap_or(&[]))
|
||||
}
|
||||
}
|
||||
|
||||
/// The extended data of one application: the groups after its `1001` up to the next `1001`.
|
||||
pub fn xdata<'a>(tags: &'a [Tag], app: &str) -> &'a [Tag] {
|
||||
let Some(start) = tags.iter().position(|t| t.code == 1001 && t.str_ref().is_some_and(|s| s.trim().eq_ignore_ascii_case(app))) else {
|
||||
return &[];
|
||||
};
|
||||
let rest = tags.get(start + 1..).unwrap_or(&[]);
|
||||
let end = rest.iter().position(|t| t.code == 1001).unwrap_or(rest.len());
|
||||
rest.get(..end).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The groups inside `1000 <name>`, `1002 {` ... `1002 }` of an xdata run (nested braces kept).
|
||||
pub fn xdata_list<'a>(x: &'a [Tag], name: &str) -> &'a [Tag] {
|
||||
let Some(at) = x.iter().position(|t| t.code == 1000 && t.str_ref().is_some_and(|s| s.trim().eq_ignore_ascii_case(name))) else {
|
||||
return &[];
|
||||
};
|
||||
if !x.get(at + 1).is_some_and(|t| t.code == 1002 && t.str_ref().is_some_and(|s| s.trim() == "{")) {
|
||||
return &[];
|
||||
}
|
||||
let body = x.get(at + 2..).unwrap_or(&[]);
|
||||
let mut depth = 0usize;
|
||||
for (i, t) in body.iter().enumerate() {
|
||||
if t.code == 1002 {
|
||||
match t.str_ref().map(str::trim) {
|
||||
Some("{") => depth += 1,
|
||||
Some("}") if depth == 0 => return body.get(..i).unwrap_or(&[]),
|
||||
Some("}") => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// The tags outside application-defined groups (`102 {` to `102 }`, whose codes are the
|
||||
/// application's), with their positions in `tags`.
|
||||
pub fn outside_app(tags: &[Tag]) -> impl Iterator<Item = (usize, &Tag)> {
|
||||
let mut in_app = false;
|
||||
tags.iter().enumerate().filter(move |(_, t)| {
|
||||
if t.code == 102 {
|
||||
in_app = t.str_ref().is_some_and(|s| s.starts_with('{'));
|
||||
}
|
||||
!in_app && t.code != 102
|
||||
})
|
||||
}
|
||||
|
||||
/// A colour as a record gives it: group 62 (index), overridden by 420 (true colour).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Color {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
/// A colour index, 1–255 ([`aci::rgb`]).
|
||||
Index(u8),
|
||||
Rgb([u8; 3]),
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// The colour of a group 62 value: 0 ByBlock, 1–255 an index, anything else (256, or a
|
||||
/// negative number) ByLayer.
|
||||
pub fn from_index(v: i64) -> Color {
|
||||
match u8::try_from(v) {
|
||||
Ok(0) => Color::ByBlock,
|
||||
Ok(i) => Color::Index(i),
|
||||
Err(_) => Color::ByLayer,
|
||||
}
|
||||
}
|
||||
/// The colour of a group 420 value (`0x00RRGGBB`).
|
||||
pub fn from_true(v: i64) -> Color {
|
||||
let [_, r, g, b] = u32::try_from(v & 0xff_ffff).unwrap_or(0).to_be_bytes();
|
||||
Color::Rgb([r, g, b])
|
||||
}
|
||||
/// The colour of a record: its 420 when it has one, else its 62.
|
||||
pub fn of(g: &Groups<'_>) -> Color {
|
||||
match (g.i(420), g.i(62)) {
|
||||
(Some(tc), _) => Color::from_true(tc),
|
||||
(None, Some(v)) => Color::from_index(v),
|
||||
(None, None) => Color::ByLayer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lineweight (group 370): hundredths of a millimetre, or one of the three special values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Lineweight {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
/// The drawing's default (`$LWDEFAULT`).
|
||||
Default,
|
||||
/// Hundredths of a millimetre (0–211).
|
||||
Hundredths(u16),
|
||||
}
|
||||
|
||||
impl Lineweight {
|
||||
/// -1 ByLayer, -2 ByBlock, -3 the default, else hundredths of a millimetre. Any other
|
||||
/// negative number is ByLayer.
|
||||
pub fn from_dxf(v: i64) -> Lineweight {
|
||||
match v {
|
||||
-2 => Lineweight::ByBlock,
|
||||
-3 => Lineweight::Default,
|
||||
v => u16::try_from(v).map_or(Lineweight::ByLayer, Lineweight::Hundredths),
|
||||
}
|
||||
}
|
||||
pub fn to_dxf(self) -> i64 {
|
||||
match self {
|
||||
Lineweight::ByLayer => -1,
|
||||
Lineweight::ByBlock => -2,
|
||||
Lineweight::Default => -3,
|
||||
Lineweight::Hundredths(v) => i64::from(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A transparency (group 440, and the `AcCmTransparency` extended data of a layer).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Transparency {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
/// Opacity: 255 opaque, 0 clear.
|
||||
Alpha(u8),
|
||||
}
|
||||
|
||||
impl Transparency {
|
||||
/// The top byte says which: `0x01` ByBlock, `0x02` an alpha in the low byte, anything else
|
||||
/// ByLayer.
|
||||
pub fn from_dxf(v: i64) -> Transparency {
|
||||
match (v >> 24) & 0xff {
|
||||
1 => Transparency::ByBlock,
|
||||
2 => Transparency::Alpha(u8::try_from(v & 0xff).unwrap_or(255)),
|
||||
_ => Transparency::ByLayer,
|
||||
}
|
||||
}
|
||||
pub fn to_dxf(self) -> i64 {
|
||||
match self {
|
||||
Transparency::ByLayer => 0,
|
||||
Transparency::ByBlock => 0x0100_0000,
|
||||
Transparency::Alpha(a) => 0x0200_0000 | i64::from(a),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cross(a: Point, b: Point) -> Point {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn unit(v: Point) -> Option<Point> {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
(len.is_finite() && len > 1e-12).then(|| [v[0] / len, v[1] / len, v[2] / len])
|
||||
}
|
||||
|
||||
/// The X, Y and Z axes of the object coordinate system whose extrusion direction (groups
|
||||
/// 210/220/230) is `normal`: the DXF Reference's arbitrary axis algorithm. A normal that is no
|
||||
/// direction (zero, or not finite) gives the world axes.
|
||||
pub fn ocs_axes(normal: Point) -> [Point; 3] {
|
||||
const WORLD: [Point; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let Some(z) = unit(normal) else { return WORLD };
|
||||
// Near the world Z axis the X axis comes from world Y, elsewhere from world Z.
|
||||
let near_z = z[0].abs() < 1.0 / 64.0 && z[1].abs() < 1.0 / 64.0;
|
||||
let Some(x) = unit(cross(if near_z { WORLD[1] } else { WORLD[2] }, z)) else { return WORLD };
|
||||
[x, cross(z, x), z]
|
||||
}
|
||||
|
||||
/// A point of the object coordinate system of `normal` in world coordinates.
|
||||
pub fn ocs_to_wcs(normal: Point, p: Point) -> Point {
|
||||
let [x, y, z] = ocs_axes(normal);
|
||||
[x[0] * p[0] + y[0] * p[1] + z[0] * p[2], x[1] * p[0] + y[1] * p[1] + z[1] * p[2], x[2] * p[0] + y[2] * p[1] + z[2] * p[2]]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The 256-colour index palette CAD apps share (the AutoCAD Color Index), generated rather than
|
||||
//! tabulated: indices 1–9 are the standard colours, 10–249 run through 24 hues at five
|
||||
//! brightnesses each in a full and a half-saturated tone, and 250–255 are greys. From
|
||||
//! VectorCraft's `vectorcraft-cad`.
|
||||
|
||||
/// The nine standard colours (index 7 shows white on a dark background and black on paper).
|
||||
const STANDARD: [[u8; 3]; 9] =
|
||||
[[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]];
|
||||
|
||||
/// The brightness of the five shades of each hue.
|
||||
const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15];
|
||||
|
||||
const WHITE: [u8; 3] = [255; 3];
|
||||
|
||||
/// The RGB of colour index `i` (1–255; 0 is index 7's white).
|
||||
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "the channels computed here are within 0.0..=255.0")]
|
||||
pub fn rgb(i: u8) -> [u8; 3] {
|
||||
match i {
|
||||
1..=9 => STANDARD.get(usize::from(i - 1)).copied().unwrap_or(WHITE),
|
||||
10..=249 => {
|
||||
let hue = f64::from(i / 10 - 1) * 15.0;
|
||||
let k = i % 10;
|
||||
let v = SHADES.get(usize::from(k / 2)).copied().unwrap_or(1.0);
|
||||
let pastel = k % 2 == 1;
|
||||
hue_rgb(hue).map(|f| {
|
||||
let f = if pastel { f + (1.0 - f) / 2.0 } else { f };
|
||||
(255.0 * v * f).floor() as u8
|
||||
})
|
||||
}
|
||||
250..=255 => [(51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8; 3],
|
||||
0 => WHITE,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels.
|
||||
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "h is within 0.0..6.0")]
|
||||
fn hue_rgb(hue: f64) -> [f64; 3] {
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
match h as u8 {
|
||||
0 => [1.0, x, 0.0],
|
||||
1 => [x, 1.0, 0.0],
|
||||
2 => [0.0, 1.0, x],
|
||||
3 => [0.0, x, 1.0],
|
||||
4 => [x, 0.0, 1.0],
|
||||
_ => [1.0, 0.0, x],
|
||||
}
|
||||
}
|
||||
|
||||
/// The index among `candidates` nearest to `color` (squared RGB distance; ties go to the earlier
|
||||
/// candidate), or 7 when there are none. Index 7 stands for both white and black, as CAD apps
|
||||
/// draw it in the background's contrast. A writer limited to 8 or 16 colours passes those.
|
||||
pub fn nearest(color: [u8; 3], candidates: impl IntoIterator<Item = u8>) -> u8 {
|
||||
let dist = |c: [u8; 3]| color.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::<i32>();
|
||||
candidates.into_iter().min_by_key(|i| if *i == 7 { dist(WHITE).min(dist([0; 3])) } else { dist(rgb(*i)) }).unwrap_or(7)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{nearest, rgb};
|
||||
|
||||
#[test]
|
||||
fn the_palette_matches_its_known_entries() {
|
||||
let known: [(u8, [u8; 3]); 14] = [
|
||||
(1, [255, 0, 0]),
|
||||
(10, [255, 0, 0]),
|
||||
(11, [255, 127, 127]),
|
||||
(12, [165, 0, 0]),
|
||||
(13, [165, 82, 82]),
|
||||
(17, [76, 38, 38]),
|
||||
(19, [38, 19, 19]),
|
||||
(20, [255, 63, 0]),
|
||||
(21, [255, 159, 127]),
|
||||
(30, [255, 127, 0]),
|
||||
(140, [0, 191, 255]),
|
||||
(250, [51, 51, 51]),
|
||||
(252, [132, 132, 132]),
|
||||
(255, [255, 255, 255]),
|
||||
];
|
||||
for (i, want) in known {
|
||||
assert_eq!(rgb(i), want, "index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_index_among_the_candidates() {
|
||||
let sixteen = || (1..=9).chain([30]).chain(250..=255);
|
||||
assert_eq!(nearest([255, 0, 0], 1..=8), 1);
|
||||
assert_eq!(nearest([0, 0, 0], 1..=255), 7, "black is index 7");
|
||||
assert_eq!(nearest([255, 255, 255], sixteen()), 7);
|
||||
assert_eq!(nearest([255, 127, 0], 1..=255), 30);
|
||||
assert_eq!(nearest([255, 127, 0], sixteen()), 30);
|
||||
assert_eq!(nearest([255, 127, 0], 1..=8), 1, "8 colours: red is nearest");
|
||||
assert_eq!(nearest([60, 60, 60], sixteen()), 250);
|
||||
assert_eq!(nearest([100, 100, 100], 1..=8), 8);
|
||||
assert_eq!(nearest([60, 60, 60], 1..=8), 7, "dark greys go to black");
|
||||
assert_eq!(nearest([1, 2, 3], []), 7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! The sections of a drawing: header variables, tables, blocks, entities and objects, each
|
||||
//! record a slice of the tag list.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::tables::{AppId, BlockRecord, DimStyle, Layer, Linetype, TextStyle, Ucs, View, Vport};
|
||||
use super::{Groups, Point, parse_handle};
|
||||
use crate::{SectionRef, Tag, Version, section_slices};
|
||||
|
||||
/// One record: its type (the value of its `0` group, trimmed) and the groups up to the next one.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Record<'a> {
|
||||
pub kind: &'a str,
|
||||
pub tags: &'a [Tag],
|
||||
}
|
||||
|
||||
impl<'a> Record<'a> {
|
||||
pub fn groups(&self) -> Groups<'a> {
|
||||
Groups(self.tags)
|
||||
}
|
||||
/// The record's handle: group 5, or 105 in a DIMSTYLE record.
|
||||
pub fn handle(&self) -> Option<u64> {
|
||||
self.groups().handle(if self.kind == "DIMSTYLE" { 105 } else { 5 })
|
||||
}
|
||||
/// The record's owner: the first group 330 outside application-defined groups.
|
||||
pub fn owner(&self) -> Option<u64> {
|
||||
super::outside_app(self.tags).find(|(_, t)| t.code == 330).and_then(|(_, t)| parse_handle(&t.str()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The records of a run of tags: each from a `0` group up to the next. Tags before the first `0`
|
||||
/// belong to no record and are left out.
|
||||
pub fn records(tags: &[Tag]) -> Vec<Record<'_>> {
|
||||
let starts: Vec<usize> = tags.iter().enumerate().filter(|(_, t)| t.code == 0).map(|(i, _)| i).collect();
|
||||
starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &i)| {
|
||||
let end = starts.get(n + 1).copied().unwrap_or(tags.len());
|
||||
let kind = tags.get(i).and_then(Tag::str_ref).unwrap_or_default().trim();
|
||||
Record { kind, tags: tags.get(i + 1..end).unwrap_or(&[]) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An entity with the records that belong to it: a POLYLINE's vertices or an INSERT's attributes,
|
||||
/// and the SEQEND closing them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntityRecord<'a> {
|
||||
pub kind: &'a str,
|
||||
pub tags: &'a [Tag],
|
||||
/// The VERTEX records of a POLYLINE, or the ATTRIB records of an INSERT.
|
||||
pub followers: Vec<Record<'a>>,
|
||||
pub seqend: Option<Record<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> EntityRecord<'a> {
|
||||
pub fn record(&self) -> Record<'a> {
|
||||
Record { kind: self.kind, tags: self.tags }
|
||||
}
|
||||
pub fn groups(&self) -> Groups<'a> {
|
||||
Groups(self.tags)
|
||||
}
|
||||
/// In paper space (group 67 is 1).
|
||||
pub fn paper_space(&self) -> bool {
|
||||
self.groups().i(67) == Some(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The entities among `records`, each with its followers, and how many records were strays: a
|
||||
/// VERTEX or ATTRIB with no POLYLINE or INSERT before it (a stray SEQEND holds nothing and is not
|
||||
/// counted).
|
||||
pub fn entity_records<'a>(records: &[Record<'a>]) -> (Vec<EntityRecord<'a>>, usize) {
|
||||
let mut out = Vec::new();
|
||||
let mut strays = 0usize;
|
||||
let mut i = 0;
|
||||
while let Some(head) = records.get(i) {
|
||||
i += 1;
|
||||
let follower = match head.kind {
|
||||
"POLYLINE" => "VERTEX",
|
||||
"INSERT" => "ATTRIB",
|
||||
"VERTEX" | "ATTRIB" => {
|
||||
strays = strays.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
"SEQEND" => continue,
|
||||
_ => "",
|
||||
};
|
||||
let mut e = EntityRecord { kind: head.kind, tags: head.tags, followers: Vec::new(), seqend: None };
|
||||
if !follower.is_empty() {
|
||||
while let Some(r) = records.get(i).filter(|r| r.kind == follower) {
|
||||
e.followers.push(*r);
|
||||
i += 1;
|
||||
}
|
||||
// The SEQEND belongs to an entity that has followers, or says it has (group 66).
|
||||
if let Some(r) = records.get(i).filter(|r| r.kind == "SEQEND" && (!e.followers.is_empty() || e.groups().i(66) == Some(1))) {
|
||||
e.seqend = Some(*r);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
(out, strays)
|
||||
}
|
||||
|
||||
/// The header variables: each `9 $NAME` with the groups that follow it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Header<'a> {
|
||||
/// In file order; the name as written, with its `$`.
|
||||
pub vars: Vec<(&'a str, &'a [Tag])>,
|
||||
}
|
||||
|
||||
impl<'a> Header<'a> {
|
||||
fn read(tags: &'a [Tag]) -> Self {
|
||||
let starts: Vec<usize> = tags.iter().enumerate().filter(|(_, t)| t.code == 9).map(|(i, _)| i).collect();
|
||||
let vars = starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, &i)| {
|
||||
let end = starts.get(n + 1).copied().unwrap_or(tags.len());
|
||||
(tags.get(i).and_then(Tag::str_ref).unwrap_or_default().trim(), tags.get(i + 1..end).unwrap_or(&[]))
|
||||
})
|
||||
.collect();
|
||||
Header { vars }
|
||||
}
|
||||
/// The groups of variable `name` (any case, with or without its `$`); the first one of
|
||||
/// that name.
|
||||
pub fn var(&self, name: &str) -> Option<Groups<'a>> {
|
||||
let name = name.trim_start_matches('$');
|
||||
self.vars.iter().find(|(n, _)| n.trim_start_matches('$').eq_ignore_ascii_case(name)).map(|(_, g)| Groups(g))
|
||||
}
|
||||
/// A variable of one number.
|
||||
pub fn f(&self, name: &str) -> Option<f64> {
|
||||
self.var(name)?.0.first().map(Tag::f64)
|
||||
}
|
||||
/// A variable of one whole number.
|
||||
pub fn i(&self, name: &str) -> Option<i64> {
|
||||
self.var(name)?.0.first().map(Tag::i64)
|
||||
}
|
||||
/// A variable of one string or handle.
|
||||
pub fn s(&self, name: &str) -> Option<String> {
|
||||
self.var(name)?.0.first().map(Tag::str)
|
||||
}
|
||||
/// A point variable (groups 10/20/30).
|
||||
pub fn point(&self, name: &str) -> Option<Point> {
|
||||
self.var(name)?.point_opt(10)
|
||||
}
|
||||
/// `$ACADVER` (`AC1032`...), trimmed.
|
||||
pub fn acadver(&self) -> Option<String> {
|
||||
self.s("ACADVER").map(|v| v.trim().to_string())
|
||||
}
|
||||
/// The version this crate writes that `$ACADVER` names or is nearest to.
|
||||
pub fn version(&self) -> Option<Version> {
|
||||
Version::for_acadver(&self.acadver()?)
|
||||
}
|
||||
/// `$INSUNITS`: the drawing unit (0: unitless, also when absent).
|
||||
pub fn insunits(&self) -> i64 {
|
||||
self.i("INSUNITS").unwrap_or(0)
|
||||
}
|
||||
/// `$MEASUREMENT`: 0 imperial, 1 metric.
|
||||
pub fn measurement(&self) -> Option<i64> {
|
||||
self.i("MEASUREMENT")
|
||||
}
|
||||
/// `$LTSCALE`: the global linetype scale (1 when absent or not positive).
|
||||
pub fn ltscale(&self) -> f64 {
|
||||
self.f("LTSCALE").filter(|v| *v > 0.0).unwrap_or(1.0)
|
||||
}
|
||||
/// `$LWDEFAULT`: the default lineweight in hundredths of a millimetre (25 when absent or
|
||||
/// negative).
|
||||
pub fn lwdefault(&self) -> u16 {
|
||||
self.i("LWDEFAULT").and_then(|v| u16::try_from(v).ok()).unwrap_or(25)
|
||||
}
|
||||
/// `$HANDSEED`: the next handle the writer of the file would have used.
|
||||
pub fn handseed(&self) -> Option<u64> {
|
||||
self.s("HANDSEED").and_then(|h| parse_handle(&h))
|
||||
}
|
||||
}
|
||||
|
||||
/// A block definition: its BLOCK record, the entities up to its ENDBLK.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Block<'a> {
|
||||
/// The BLOCK record.
|
||||
pub tags: &'a [Tag],
|
||||
/// Group 2, else group 3, trimmed; empty when the record names none.
|
||||
pub name: &'a str,
|
||||
pub entities: Vec<EntityRecord<'a>>,
|
||||
/// The ENDBLK record; `None` when the section ended first.
|
||||
pub end: Option<Record<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Block<'a> {
|
||||
pub fn groups(&self) -> Groups<'a> {
|
||||
Groups(self.tags)
|
||||
}
|
||||
/// The base point (10/20/30).
|
||||
pub fn base(&self) -> Point {
|
||||
self.groups().point(10)
|
||||
}
|
||||
/// Group 70: 1 anonymous, 2 has attribute definitions, 4 an external reference, 8 an
|
||||
/// overlay, 16 externally dependent, 32 resolved, 64 referenced.
|
||||
pub fn flags(&self) -> i64 {
|
||||
self.groups().i_or(70, 0)
|
||||
}
|
||||
/// An external reference or overlay: its art is another drawing's ([`Block::xref_path`]).
|
||||
pub fn is_xref(&self) -> bool {
|
||||
self.flags() & 12 != 0
|
||||
}
|
||||
/// The path of the drawing an external reference names (group 1).
|
||||
pub fn xref_path(&self) -> Option<String> {
|
||||
self.groups().s(1).filter(|p| !p.is_empty())
|
||||
}
|
||||
/// Group 4.
|
||||
pub fn description(&self) -> Option<String> {
|
||||
self.groups().s(4).filter(|p| !p.is_empty())
|
||||
}
|
||||
/// The block's BLOCK_RECORD (group 330, R13 and later).
|
||||
pub fn block_record(&self) -> Option<u64> {
|
||||
Record { kind: "BLOCK", tags: self.tags }.owner()
|
||||
}
|
||||
}
|
||||
|
||||
/// A LAYOUT object.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Layout {
|
||||
pub handle: Option<u64>,
|
||||
/// The tab's name (`Model` for model space).
|
||||
pub name: String,
|
||||
/// Group 71: the tab's place (0 for model space).
|
||||
pub tab_order: i64,
|
||||
/// The BLOCK_RECORD that holds the layout's entities ([`Document::block_name`]).
|
||||
pub block_record: Option<u64>,
|
||||
/// Groups 10/20 and 11/21: the limits, when both corners are given.
|
||||
pub limits: Option<([f64; 2], [f64; 2])>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The typed form of a LAYOUT record. Its own groups follow the `AcDbLayout` marker (the
|
||||
/// plot settings before it reuse the codes); a record without the marker is read whole.
|
||||
pub fn read(r: &Record<'_>) -> Option<Layout> {
|
||||
let start = r.tags.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == "AcDbLayout")).unwrap_or(0);
|
||||
let own = Groups(r.tags.get(start..).unwrap_or(&[]));
|
||||
let name = own.name(1)?;
|
||||
let corner = |x: i32, y: i32| Some([own.f(x)?, own.f(y)?]);
|
||||
// A LAYOUT has two 330s, its dictionary and its block record: the block record is the
|
||||
// one among its own groups, or the last where there is no marker.
|
||||
let block_record = own.handle(330).or_else(|| r.tags.iter().rfind(|t| t.code == 330).and_then(|t| parse_handle(&t.str())));
|
||||
Some(Layout { handle: r.handle(), name, tab_order: own.i_or(71, 0), block_record, limits: corner(10, 20).zip(corner(11, 21)) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A DICTIONARY object: names (group 3) and the handles of what they name (350 or 360).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Dictionary {
|
||||
pub handle: Option<u64>,
|
||||
pub entries: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl Dictionary {
|
||||
pub fn read(r: &Record<'_>) -> Dictionary {
|
||||
let mut entries = Vec::new();
|
||||
let mut name: Option<String> = None;
|
||||
for t in r.tags {
|
||||
match t.code {
|
||||
3 => name = Some(t.str()),
|
||||
350 | 360 => {
|
||||
if let (Some(n), Some(h)) = (name.take(), parse_handle(&t.str())) {
|
||||
entries.push((n, h));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Dictionary { handle: r.handle(), entries }
|
||||
}
|
||||
/// The handle named `name` (any case).
|
||||
pub fn get(&self, name: &str) -> Option<u64> {
|
||||
self.entries.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, h)| *h)
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing: the records of each section of a tag list.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Document<'a> {
|
||||
pub header: Header<'a>,
|
||||
/// The CLASS records of the CLASSES section.
|
||||
pub classes: Vec<Record<'a>>,
|
||||
/// The records of the TABLES section in file order, the `TABLE` and `ENDTAB` records
|
||||
/// included. The typed tables ([`Document::layers`]...) read them by record type.
|
||||
pub tables: Vec<Record<'a>>,
|
||||
pub blocks: Vec<Block<'a>>,
|
||||
/// The ENTITIES section. A damaged file may split its entities over several: they are joined.
|
||||
pub entities: Vec<EntityRecord<'a>>,
|
||||
/// The OBJECTS section.
|
||||
pub objects: Vec<Record<'a>>,
|
||||
/// Every section this model has no place for (THUMBNAILIMAGE, ACDSDATA, an unknown name),
|
||||
/// as it was split.
|
||||
pub other_sections: Vec<SectionRef<'a>>,
|
||||
/// How many sections the file has (none: not a drawing).
|
||||
pub sections: usize,
|
||||
/// Records the model left out, by section name (`(no section)` for records between
|
||||
/// sections): stray VERTEX and ATTRIB records, and records of BLOCKS outside any block.
|
||||
pub skipped: Vec<(&'static str, usize)>,
|
||||
}
|
||||
|
||||
/// Records between sections: everything outside a SECTION ... ENDSEC pair. Stray ENDSECs and the
|
||||
/// EOF hold nothing and don't count.
|
||||
fn outside_sections(tags: &[Tag]) -> usize {
|
||||
let mut inside = false;
|
||||
let mut n = 0usize;
|
||||
for t in tags.iter().filter(|t| t.code == 0) {
|
||||
match t.str_ref().map(str::trim_end) {
|
||||
Some("SECTION") => inside = true,
|
||||
Some("ENDSEC") => inside = false,
|
||||
Some("EOF") => {}
|
||||
_ if !inside => n = n.saturating_add(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
impl<'a> Document<'a> {
|
||||
/// Index the sections of `tags`.
|
||||
pub fn read(tags: &'a [Tag]) -> Self {
|
||||
let mut d = Document::default();
|
||||
let skip = |d: &mut Document<'a>, section: &'static str, n: usize| {
|
||||
if n > 0 {
|
||||
match d.skipped.iter_mut().find(|(s, _)| *s == section) {
|
||||
Some((_, count)) => *count = count.saturating_add(n),
|
||||
None => d.skipped.push((section, n)),
|
||||
}
|
||||
}
|
||||
};
|
||||
skip(&mut d, "(no section)", outside_sections(tags));
|
||||
for s in section_slices(tags) {
|
||||
d.sections += 1;
|
||||
match s.name {
|
||||
// A second HEADER adds its variables after the first's: the first of a name wins.
|
||||
"HEADER" => d.header.vars.extend(Header::read(s.tags).vars),
|
||||
"CLASSES" => d.classes.extend(records(s.tags)),
|
||||
"TABLES" => d.tables.extend(records(s.tags)),
|
||||
"BLOCKS" => {
|
||||
let n = d.read_blocks(&records(s.tags));
|
||||
skip(&mut d, "BLOCKS", n);
|
||||
}
|
||||
"ENTITIES" => {
|
||||
let (entities, strays) = entity_records(&records(s.tags));
|
||||
d.entities.extend(entities);
|
||||
skip(&mut d, "ENTITIES", strays);
|
||||
}
|
||||
"OBJECTS" => d.objects.extend(records(s.tags)),
|
||||
_ => d.other_sections.push(s),
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The blocks of a BLOCKS section; returns how many records were left out.
|
||||
fn read_blocks(&mut self, recs: &[Record<'a>]) -> usize {
|
||||
let mut skipped = 0usize;
|
||||
let mut i = 0;
|
||||
while let Some(r) = recs.get(i) {
|
||||
i += 1;
|
||||
if r.kind != "BLOCK" {
|
||||
// A record outside any BLOCK ... ENDBLK pair.
|
||||
skipped = skipped.saturating_add(usize::from(r.kind != "ENDBLK"));
|
||||
continue;
|
||||
}
|
||||
let body_len = recs.get(i..).unwrap_or(&[]).iter().position(|r| r.kind == "ENDBLK" || r.kind == "BLOCK");
|
||||
let body = recs.get(i..i + body_len.unwrap_or(recs.len() - i)).unwrap_or(&[]);
|
||||
i += body.len();
|
||||
let end = recs.get(i).filter(|r| r.kind == "ENDBLK").copied();
|
||||
i += usize::from(end.is_some());
|
||||
let (entities, strays) = entity_records(body);
|
||||
skipped = skipped.saturating_add(strays);
|
||||
let g = r.groups();
|
||||
let name = [2, 3].iter().filter_map(|c| g.0.iter().find(|t| t.code == *c)?.str_ref()).map(str::trim).find(|n| !n.is_empty());
|
||||
self.blocks.push(Block { tags: r.tags, name: name.unwrap_or_default(), entities, end });
|
||||
}
|
||||
skipped
|
||||
}
|
||||
|
||||
fn table<T>(&self, kind: &str, read: impl Fn(&Record<'a>) -> Option<T>) -> Vec<T> {
|
||||
self.tables.iter().filter(|r| r.kind == kind).filter_map(read).collect()
|
||||
}
|
||||
/// The LAYER records, in file order (a name may repeat: which wins is the caller's).
|
||||
pub fn layers(&self) -> Vec<Layer> {
|
||||
self.table("LAYER", Layer::read)
|
||||
}
|
||||
pub fn linetypes(&self) -> Vec<Linetype> {
|
||||
self.table("LTYPE", Linetype::read)
|
||||
}
|
||||
pub fn text_styles(&self) -> Vec<TextStyle> {
|
||||
self.table("STYLE", TextStyle::read)
|
||||
}
|
||||
pub fn dim_styles(&self) -> Vec<DimStyle<'a>> {
|
||||
self.table("DIMSTYLE", DimStyle::read)
|
||||
}
|
||||
pub fn block_records(&self) -> Vec<BlockRecord> {
|
||||
self.table("BLOCK_RECORD", BlockRecord::read)
|
||||
}
|
||||
pub fn views(&self) -> Vec<View> {
|
||||
self.table("VIEW", View::read)
|
||||
}
|
||||
pub fn ucss(&self) -> Vec<Ucs> {
|
||||
self.table("UCS", Ucs::read)
|
||||
}
|
||||
pub fn vports(&self) -> Vec<Vport> {
|
||||
self.table("VPORT", Vport::read)
|
||||
}
|
||||
pub fn app_ids(&self) -> Vec<AppId> {
|
||||
self.table("APPID", AppId::read)
|
||||
}
|
||||
|
||||
/// Table record handle → name for the records of type `kind` (`LAYER`, `STYLE`, `LTYPE`...):
|
||||
/// what a handle group (a DIMSTYLE's 340, a VIEWPORT's 331) names.
|
||||
pub fn table_names(&self, kind: &str) -> HashMap<u64, &'a str> {
|
||||
let name = |r: &Record<'a>| r.tags.iter().find(|t| t.code == 2).and_then(Tag::str_ref);
|
||||
self.tables.iter().filter(|r| r.kind == kind).filter_map(|r| Some((r.handle()?, name(r)?))).collect()
|
||||
}
|
||||
|
||||
/// BLOCK_RECORD handle → block name: from the BLOCK_RECORD table, and from each BLOCK that
|
||||
/// names its record (group 330) where the table has none for it.
|
||||
pub fn block_names(&self) -> HashMap<u64, &'a str> {
|
||||
let mut names = self.table_names("BLOCK_RECORD");
|
||||
for b in &self.blocks {
|
||||
if let Some(h) = b.block_record() {
|
||||
names.entry(h).or_insert(b.name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The block named `name` (any case); the first of that name.
|
||||
pub fn block(&self, name: &str) -> Option<&Block<'a>> {
|
||||
self.blocks.iter().find(|b| b.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The LAYOUT objects, in file order.
|
||||
pub fn layouts(&self) -> Vec<Layout> {
|
||||
self.objects.iter().filter(|r| r.kind == "LAYOUT").filter_map(Layout::read).collect()
|
||||
}
|
||||
|
||||
/// The DICTIONARY with handle `handle`.
|
||||
pub fn dictionary(&self, handle: u64) -> Option<Dictionary> {
|
||||
self.objects.iter().find(|r| r.kind == "DICTIONARY" && r.handle() == Some(handle)).map(Dictionary::read)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! The symbol table records, typed.
|
||||
|
||||
use super::document::Record;
|
||||
use super::{Color, Lineweight, Point, Transparency, xdata, xdata_list};
|
||||
use crate::Tag;
|
||||
|
||||
/// A LAYER record.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Layer {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
/// The layer's colour: an index (1–255) or a true colour.
|
||||
pub color: Color,
|
||||
/// Switched on (a negative colour index switches a layer off).
|
||||
pub on: bool,
|
||||
/// Flags (70) bit 1.
|
||||
pub frozen: bool,
|
||||
/// Flags bit 2: frozen in new viewports.
|
||||
pub vp_freeze_new: bool,
|
||||
/// Flags bit 4.
|
||||
pub locked: bool,
|
||||
/// Group 290 (plotted unless it is 0). CAD apps never plot the layer `Defpoints` either,
|
||||
/// which is the caller's to apply.
|
||||
pub plot: bool,
|
||||
/// Group 6 as written (`Continuous` when absent).
|
||||
pub linetype: String,
|
||||
pub lineweight: Lineweight,
|
||||
/// Group 390: the plot style name object.
|
||||
pub plot_style: Option<u64>,
|
||||
/// The `AcCmTransparency` extended data.
|
||||
pub transparency: Transparency,
|
||||
/// The description in the `AcAecLayerStandard` extended data (its second string).
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// `None` for a record without a name.
|
||||
pub fn read(r: &Record<'_>) -> Option<Layer> {
|
||||
let g = r.groups();
|
||||
let name = g.name(2)?;
|
||||
let aci = g.i_or(62, 7);
|
||||
let flags = g.i_or(70, 0);
|
||||
let index = u8::try_from(aci.unsigned_abs().clamp(1, 255)).unwrap_or(7);
|
||||
let alpha = xdata(r.tags, "AcCmTransparency").iter().find(|t| t.code == 1071).map(Tag::i64);
|
||||
Some(Layer {
|
||||
handle: r.handle(),
|
||||
name,
|
||||
color: g.i(420).map_or(Color::Index(index), Color::from_true),
|
||||
on: aci >= 0,
|
||||
frozen: flags & 1 != 0,
|
||||
vp_freeze_new: flags & 2 != 0,
|
||||
locked: flags & 4 != 0,
|
||||
plot: g.i_or(290, 1) != 0,
|
||||
linetype: g.name(6).unwrap_or_else(|| "Continuous".into()),
|
||||
lineweight: g.i(370).map_or(Lineweight::Default, Lineweight::from_dxf),
|
||||
plot_style: g.handle(390),
|
||||
transparency: alpha.map_or(Transparency::ByLayer, Transparency::from_dxf),
|
||||
description: xdata(r.tags, "AcAecLayerStandard").iter().filter(|t| t.code == 1000).nth(1).map(Tag::str),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One element of a linetype's pattern: a dash, gap or dot, with the text or shape drawn at it.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct DashElement {
|
||||
/// Group 49: a dash (positive), a gap (negative) or a dot (0).
|
||||
pub length: f64,
|
||||
/// Group 74 bit 1: the rotation is absolute, not relative to the line.
|
||||
pub absolute: bool,
|
||||
/// Group 9, when group 74 bit 2 says the element draws text.
|
||||
pub text: Option<String>,
|
||||
/// Group 75, when group 74 bit 4 says the element draws a shape.
|
||||
pub shape: Option<u16>,
|
||||
/// Group 340: the STYLE of the text or shape.
|
||||
pub style: Option<u64>,
|
||||
/// Group 46 (1 when absent).
|
||||
pub scale: f64,
|
||||
/// Group 50, as stored.
|
||||
pub rotation: f64,
|
||||
/// Groups 44 and 45.
|
||||
pub offset: [f64; 2],
|
||||
}
|
||||
|
||||
/// An LTYPE record.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Linetype {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
/// Group 3.
|
||||
pub description: String,
|
||||
/// Group 40: the pattern's length as the file states it.
|
||||
pub length: f64,
|
||||
pub pattern: Vec<DashElement>,
|
||||
}
|
||||
|
||||
impl Linetype {
|
||||
pub fn read(r: &Record<'_>) -> Option<Linetype> {
|
||||
let g = r.groups();
|
||||
let mut pattern: Vec<DashElement> = Vec::new();
|
||||
for t in r.tags.iter().take_while(|t| t.code < 1000) {
|
||||
if t.code == 49 {
|
||||
pattern.push(DashElement { length: t.f64(), scale: 1.0, ..DashElement::default() });
|
||||
continue;
|
||||
}
|
||||
let Some(el) = pattern.last_mut() else { continue };
|
||||
let complex = el.text.is_some() || el.shape.is_some();
|
||||
match t.code {
|
||||
74 => {
|
||||
let flags = t.i64();
|
||||
el.absolute = flags & 1 != 0;
|
||||
if flags & 2 != 0 {
|
||||
el.text = Some(String::new());
|
||||
} else if flags & 4 != 0 {
|
||||
el.shape = Some(0);
|
||||
}
|
||||
}
|
||||
75 if el.shape.is_some() => el.shape = Some(u16::try_from(t.i64()).unwrap_or(0)),
|
||||
340 if complex => el.style = super::parse_handle(&t.str()).filter(|h| *h != 0),
|
||||
9 if el.text.is_some() => el.text = Some(t.str()),
|
||||
46 if complex => el.scale = t.f64(),
|
||||
50 if complex => el.rotation = t.f64(),
|
||||
44 if complex => el.offset[0] = t.f64(),
|
||||
45 if complex => el.offset[1] = t.f64(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Linetype { handle: r.handle(), name: g.name(2)?, description: g.s(3).unwrap_or_default(), length: g.f_or(40, 0.0), pattern })
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a record carries `AcadAnnotative` extended data with the annotative flag set.
|
||||
fn annotative(tags: &[Tag]) -> bool {
|
||||
xdata_list(xdata(tags, "AcadAnnotative"), "AnnotativeData").iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0)
|
||||
}
|
||||
|
||||
/// A STYLE record: a text style, or a shape file (which has no name).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
pub handle: Option<u64>,
|
||||
/// Empty for a shape file entry.
|
||||
pub name: String,
|
||||
/// Group 3: the font file (`txt.shx`, `arial.ttf`), or the shape file.
|
||||
pub font_file: String,
|
||||
/// Group 4.
|
||||
pub big_font: String,
|
||||
/// The typeface 2000-and-later files name in the style's `ACAD` extended data (how a
|
||||
/// TrueType style is stored); `None` for a CAD font.
|
||||
pub typeface: Option<String>,
|
||||
/// Group 40: the fixed height (0: none).
|
||||
pub height: f64,
|
||||
/// Group 41 (1 when absent).
|
||||
pub width_factor: f64,
|
||||
/// Group 50, in degrees.
|
||||
pub oblique: f64,
|
||||
/// Flags (70) bit 1: a shape file entry.
|
||||
pub shape_file: bool,
|
||||
/// Flags bit 4.
|
||||
pub vertical: bool,
|
||||
/// Group 71 bit 2.
|
||||
pub backwards: bool,
|
||||
/// Group 71 bit 4.
|
||||
pub upside_down: bool,
|
||||
pub annotative: bool,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// Every STYLE record, the unnamed shape file entries included.
|
||||
pub fn read(r: &Record<'_>) -> Option<TextStyle> {
|
||||
let g = r.groups();
|
||||
let (flags, generation) = (g.i_or(70, 0), g.i_or(71, 0));
|
||||
let typeface = xdata(r.tags, "ACAD").iter().find(|t| t.code == 1000).map(|t| t.str().trim().to_string()).filter(|f| !f.is_empty());
|
||||
Some(TextStyle {
|
||||
handle: r.handle(),
|
||||
name: g.name(2).unwrap_or_default(),
|
||||
font_file: g.s(3).unwrap_or_default(),
|
||||
big_font: g.s(4).unwrap_or_default(),
|
||||
typeface,
|
||||
height: g.f_or(40, 0.0),
|
||||
width_factor: g.f_or(41, 1.0),
|
||||
oblique: g.f_or(50, 0.0),
|
||||
shape_file: flags & 1 != 0,
|
||||
vertical: flags & 4 != 0,
|
||||
backwards: generation & 2 != 0,
|
||||
upside_down: generation & 4 != 0,
|
||||
annotative: annotative(r.tags),
|
||||
})
|
||||
}
|
||||
/// The family of a TrueType style: [`TextStyle::typeface`], else the stem of a `.ttf`, `.otf`
|
||||
/// or `.ttc` font file. `None` for a CAD font (`.shx`).
|
||||
pub fn family(&self) -> Option<String> {
|
||||
if let Some(f) = &self.typeface {
|
||||
return Some(f.clone());
|
||||
}
|
||||
let file = self.font_file.trim().rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
let (stem, ext) = file.rsplit_once('.')?;
|
||||
(matches!(ext.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc") && !stem.is_empty()).then(|| stem.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// DIMSTYLE group code and the dimension variable it holds (DXF Reference, DIMSTYLE). The same
|
||||
/// codes name variables in the `DSTYLE` override extended data of a dimension. Code 70 is
|
||||
/// DIMTFILLCLR only after the record's flags, which use the code first.
|
||||
const DIM_VARS: &[(i32, &str)] = &[
|
||||
(3, "DIMPOST"),
|
||||
(4, "DIMAPOST"),
|
||||
(40, "DIMSCALE"),
|
||||
(41, "DIMASZ"),
|
||||
(42, "DIMEXO"),
|
||||
(43, "DIMDLI"),
|
||||
(44, "DIMEXE"),
|
||||
(45, "DIMRND"),
|
||||
(46, "DIMDLE"),
|
||||
(47, "DIMTP"),
|
||||
(48, "DIMTM"),
|
||||
(49, "DIMFXL"),
|
||||
(50, "DIMJOGANG"),
|
||||
(69, "DIMTFILL"),
|
||||
(70, "DIMTFILLCLR"),
|
||||
(71, "DIMTOL"),
|
||||
(72, "DIMLIM"),
|
||||
(73, "DIMTIH"),
|
||||
(74, "DIMTOH"),
|
||||
(75, "DIMSE1"),
|
||||
(76, "DIMSE2"),
|
||||
(77, "DIMTAD"),
|
||||
(78, "DIMZIN"),
|
||||
(79, "DIMAZIN"),
|
||||
(90, "DIMARCSYM"),
|
||||
(140, "DIMTXT"),
|
||||
(141, "DIMCEN"),
|
||||
(142, "DIMTSZ"),
|
||||
(143, "DIMALTF"),
|
||||
(144, "DIMLFAC"),
|
||||
(145, "DIMTVP"),
|
||||
(146, "DIMTFAC"),
|
||||
(147, "DIMGAP"),
|
||||
(148, "DIMALTRND"),
|
||||
(170, "DIMALT"),
|
||||
(171, "DIMALTD"),
|
||||
(172, "DIMTOFL"),
|
||||
(173, "DIMSAH"),
|
||||
(174, "DIMTIX"),
|
||||
(175, "DIMSOXD"),
|
||||
(176, "DIMCLRD"),
|
||||
(177, "DIMCLRE"),
|
||||
(178, "DIMCLRT"),
|
||||
(179, "DIMADEC"),
|
||||
(271, "DIMDEC"),
|
||||
(272, "DIMTDEC"),
|
||||
(273, "DIMALTU"),
|
||||
(274, "DIMALTTD"),
|
||||
(275, "DIMAUNIT"),
|
||||
(276, "DIMFRAC"),
|
||||
(277, "DIMLUNIT"),
|
||||
(278, "DIMDSEP"),
|
||||
(279, "DIMTMOVE"),
|
||||
(280, "DIMJUST"),
|
||||
(281, "DIMSD1"),
|
||||
(282, "DIMSD2"),
|
||||
(283, "DIMTOLJ"),
|
||||
(284, "DIMTZIN"),
|
||||
(285, "DIMALTZ"),
|
||||
(286, "DIMALTTZ"),
|
||||
(288, "DIMUPT"),
|
||||
(289, "DIMATFIT"),
|
||||
(290, "DIMFXLON"),
|
||||
(340, "DIMTXSTY"),
|
||||
(341, "DIMLDRBLK"),
|
||||
(342, "DIMBLK"),
|
||||
(343, "DIMBLK1"),
|
||||
(344, "DIMBLK2"),
|
||||
(345, "DIMLTYPE"),
|
||||
(346, "DIMLTEX1"),
|
||||
(347, "DIMLTEX2"),
|
||||
(371, "DIMLWD"),
|
||||
(372, "DIMLWE"),
|
||||
];
|
||||
|
||||
/// The dimension variable a DIMSTYLE group code holds.
|
||||
pub fn dim_var_name(code: i32) -> Option<&'static str> {
|
||||
DIM_VARS.iter().find(|(c, _)| *c == code).map(|(_, n)| *n)
|
||||
}
|
||||
|
||||
/// The DIMSTYLE group code of a dimension variable (any case, with or without `$`).
|
||||
pub fn dim_var_code(name: &str) -> Option<i32> {
|
||||
let name = name.trim_start_matches('$');
|
||||
DIM_VARS.iter().find(|(_, n)| n.eq_ignore_ascii_case(name)).map(|(c, _)| *c)
|
||||
}
|
||||
|
||||
/// A DIMSTYLE record: its name and the variables it sets.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DimStyle<'a> {
|
||||
/// Group 105.
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
/// The record's flags (the first group 70).
|
||||
pub flags: i64,
|
||||
pub annotative: bool,
|
||||
/// The variables in file order: name and the group that holds the value. Angles, handles
|
||||
/// and character codes are as the file stores them (DIMJOGANG in radians, DIMTXSTY and the
|
||||
/// arrow blocks as handles of STYLE and BLOCK_RECORD records, DIMDSEP as a character code).
|
||||
pub vars: Vec<(&'static str, &'a Tag)>,
|
||||
}
|
||||
|
||||
impl<'a> DimStyle<'a> {
|
||||
pub fn read(r: &Record<'a>) -> Option<DimStyle<'a>> {
|
||||
let g = r.groups();
|
||||
// The first 70 is the record's flags; a later one is DIMTFILLCLR.
|
||||
let flags_at = r.tags.iter().position(|t| t.code == 70);
|
||||
let vars = r
|
||||
.tags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take_while(|(_, t)| t.code < 1000)
|
||||
.filter(|(i, _)| Some(*i) != flags_at)
|
||||
.filter_map(|(_, t)| Some((dim_var_name(t.code)?, t)))
|
||||
.collect();
|
||||
Some(DimStyle { handle: r.handle(), name: g.name(2)?, flags: g.i_or(70, 0), annotative: annotative(r.tags), vars })
|
||||
}
|
||||
/// The group holding variable `name`; the first.
|
||||
pub fn var(&self, name: &str) -> Option<&'a Tag> {
|
||||
let name = name.trim_start_matches('$');
|
||||
self.vars.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, t)| *t)
|
||||
}
|
||||
}
|
||||
|
||||
/// A BLOCK_RECORD.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BlockRecord {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
/// Group 340: the LAYOUT of a model or paper space block.
|
||||
pub layout: Option<u64>,
|
||||
/// Group 70: the insertion units (`$INSUNITS` codes).
|
||||
pub units: i64,
|
||||
/// Group 280 (explodable unless it is 0).
|
||||
pub explodable: bool,
|
||||
/// Group 281: scaled uniformly only.
|
||||
pub uniform_scale: bool,
|
||||
}
|
||||
|
||||
impl BlockRecord {
|
||||
pub fn read(r: &Record<'_>) -> Option<BlockRecord> {
|
||||
let g = r.groups();
|
||||
Some(BlockRecord {
|
||||
handle: r.handle(),
|
||||
name: g.name(2)?,
|
||||
layout: g.handle(340).filter(|h| *h != 0),
|
||||
units: g.i_or(70, 0),
|
||||
explodable: g.i_or(280, 1) != 0,
|
||||
uniform_scale: g.i_or(281, 0) != 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A VIEW record: a named view.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
/// Flags (70) bit 1: a paper space view.
|
||||
pub paper_space: bool,
|
||||
/// Group 40.
|
||||
pub height: f64,
|
||||
/// Group 41.
|
||||
pub width: f64,
|
||||
/// Groups 10/20: the centre in display coordinates, whose origin is the target.
|
||||
pub center: [f64; 2],
|
||||
/// Groups 11/21/31: the direction from the target.
|
||||
pub direction: Point,
|
||||
/// Groups 12/22/32.
|
||||
pub target: Point,
|
||||
/// Group 50: the twist, in degrees.
|
||||
pub twist: f64,
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn read(r: &Record<'_>) -> Option<View> {
|
||||
let g = r.groups();
|
||||
Some(View {
|
||||
handle: r.handle(),
|
||||
name: g.name(2)?,
|
||||
paper_space: g.i_or(70, 0) & 1 != 0,
|
||||
height: g.f_or(40, 0.0),
|
||||
width: g.f_or(41, 0.0),
|
||||
center: [g.f_or(10, 0.0), g.f_or(20, 0.0)],
|
||||
direction: g.point_opt(11).unwrap_or([0.0, 0.0, 1.0]),
|
||||
target: g.point(12),
|
||||
twist: g.f_or(50, 0.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A UCS record: a named coordinate system.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Ucs {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
pub origin: Point,
|
||||
pub x_axis: Point,
|
||||
pub y_axis: Point,
|
||||
}
|
||||
|
||||
impl Ucs {
|
||||
pub fn read(r: &Record<'_>) -> Option<Ucs> {
|
||||
let g = r.groups();
|
||||
Some(Ucs { handle: r.handle(), name: g.name(2)?, origin: g.point(10), x_axis: g.point(11), y_axis: g.point(12) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A VPORT record: one tile of a viewport configuration (`*ACTIVE` is the one shown).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vport {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
/// Groups 10/20 and 11/21: the tile's corners as fractions of the drawing area.
|
||||
pub min: [f64; 2],
|
||||
pub max: [f64; 2],
|
||||
/// Groups 12/22: the view centre in display coordinates, when given.
|
||||
pub center: Option<[f64; 2]>,
|
||||
/// Groups 13/23, 14/24 and 15/25.
|
||||
pub snap_base: Option<[f64; 2]>,
|
||||
pub snap_spacing: Option<[f64; 2]>,
|
||||
pub grid_spacing: Option<[f64; 2]>,
|
||||
/// Groups 16/26/36: the direction from the target.
|
||||
pub direction: Point,
|
||||
/// Groups 17/27/37.
|
||||
pub target: Point,
|
||||
/// Group 40: the view height.
|
||||
pub height: f64,
|
||||
/// Group 41: the tile's aspect ratio.
|
||||
pub aspect: Option<f64>,
|
||||
/// Group 50: the snap rotation, in degrees.
|
||||
pub snap_angle: Option<f64>,
|
||||
/// Group 51: the twist, in degrees.
|
||||
pub twist: f64,
|
||||
}
|
||||
|
||||
impl Vport {
|
||||
pub fn read(r: &Record<'_>) -> Option<Vport> {
|
||||
let g = r.groups();
|
||||
let pair = |x: i32| Some([g.f(x)?, g.f(x + 10)?]);
|
||||
Some(Vport {
|
||||
handle: r.handle(),
|
||||
name: g.name(2)?,
|
||||
min: [g.f_or(10, 0.0), g.f_or(20, 0.0)],
|
||||
max: [g.f_or(11, 0.0), g.f_or(21, 0.0)],
|
||||
center: pair(12),
|
||||
snap_base: pair(13),
|
||||
snap_spacing: pair(14),
|
||||
grid_spacing: pair(15),
|
||||
direction: g.point_opt(16).unwrap_or([0.0, 0.0, 1.0]),
|
||||
target: g.point(17),
|
||||
height: g.f_or(40, 0.0),
|
||||
aspect: g.f(41),
|
||||
snap_angle: g.f(50),
|
||||
twist: g.f_or(51, 0.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An APPID record: an application registered for extended data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AppId {
|
||||
pub handle: Option<u64>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl AppId {
|
||||
pub fn read(r: &Record<'_>) -> Option<AppId> {
|
||||
Some(AppId { handle: r.handle(), name: r.groups().name(2)? })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! The drawing model: sections, header variables, tables, blocks, layouts.
|
||||
|
||||
use craft_dxf::model::{Color, Dictionary, Document, Groups, Lineweight, Transparency, aci, dim_var_code, dim_var_name, ocs_to_wcs, parse_handle};
|
||||
use craft_dxf::{Tag, Version, parse};
|
||||
|
||||
/// The tags of `text`, whose lines alternate group code and value; `|` also ends a line, so a
|
||||
/// record fits on one, and `~` is an empty value.
|
||||
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 DRAWING: &str = "
|
||||
0|SECTION|2|HEADER
|
||||
9|$ACADVER|1|AC1027
|
||||
9|$INSUNITS|70|4
|
||||
9|$LTSCALE|40|0.0
|
||||
9|$EXTMIN|10|1.5|20|2.5|30|0.0
|
||||
9|$HANDSEED|5|FFFF
|
||||
0|ENDSEC
|
||||
0|SECTION|2|TABLES
|
||||
0|TABLE|2|LAYER|70|2
|
||||
0|LAYER|5|10|2|Walls|70|5|62|-3|6|DASHED|370|35|290|0|390|F
|
||||
1001|AcAecLayerStandard|1000|~|1000|outer walls
|
||||
1001|AcCmTransparency|1071|33554559
|
||||
0|LAYER|5|11|2|True|62|7|420|16711935
|
||||
0|LAYER|5|12|70|0
|
||||
0|ENDTAB
|
||||
0|TABLE|2|LTYPE
|
||||
0|LTYPE|5|14|2|GAS|3|Gas line|73|3|40|1.0|49|0.5|74|0|49|-0.2|74|2|75|0|340|20|46|0.1|50|15.0|44|-0.1|45|-0.05|9|GAS|49|-0.25|74|0
|
||||
0|ENDTAB
|
||||
0|TABLE|2|STYLE
|
||||
0|STYLE|5|20|2|Notes|70|4|40|2.5|41|0.8|50|15.0|71|6|3|arial.ttf|4|~
|
||||
0|STYLE|5|21|2|Face|3|~|1001|ACAD|1000|Noto Sans|1071|0
|
||||
0|STYLE|5|22|2|~|70|1|3|ltypeshp.shx
|
||||
0|STYLE|5|23|2|Cad|3|txt.shx
|
||||
0|ENDTAB
|
||||
0|TABLE|2|DIMSTYLE
|
||||
0|DIMSTYLE|105|30|2|ISO-25|70|0|41|2.5|278|44|340|20|69|2|70|5
|
||||
1001|ACAD|1000|ignored|1040|9.0
|
||||
0|ENDTAB
|
||||
0|TABLE|2|BLOCK_RECORD
|
||||
0|BLOCK_RECORD|5|40|2|*Model_Space|340|50
|
||||
0|BLOCK_RECORD|5|41|2|*Paper_Space|340|51
|
||||
0|BLOCK_RECORD|5|42|2|DOOR|70|4|280|0|281|1
|
||||
0|ENDTAB
|
||||
0|TABLE|2|VIEW
|
||||
0|VIEW|5|60|2|Front|70|1|40|10.0|41|20.0|10|1.0|20|2.0|12|5.0|22|6.0|32|7.0|50|30.0
|
||||
0|ENDTAB
|
||||
0|TABLE|2|UCS
|
||||
0|UCS|5|61|2|Site|10|1.0|20|2.0|30|3.0|11|0.0|21|1.0|31|0.0|12|-1.0|22|0.0|32|0.0
|
||||
0|ENDTAB
|
||||
0|TABLE|2|VPORT
|
||||
0|VPORT|5|62|2|*ACTIVE|10|0.0|20|0.0|11|1.0|21|1.0|12|4.0|22|5.0|14|0.5|24|0.5|40|9.0|41|1.5|51|10.0
|
||||
0|ENDTAB
|
||||
0|TABLE|2|APPID
|
||||
0|APPID|5|63|2|ACAD
|
||||
0|ENDTAB
|
||||
0|ENDSEC
|
||||
0|SECTION|2|BLOCKS
|
||||
0|LINE|8|stray
|
||||
0|BLOCK|5|70|330|42|2|DOOR|70|2|10|1.0|20|2.0|30|3.0|4|A door
|
||||
0|POLYLINE|5|71|66|1|70|1
|
||||
0|VERTEX|10|0.0|20|0.0
|
||||
0|VERTEX|10|1.0|20|0.0
|
||||
0|SEQEND|5|72
|
||||
0|VERTEX|10|9.0
|
||||
0|ENDBLK|5|73
|
||||
0|BLOCK|5|74|330|99|3|NOTABLE|70|4|1|other.dwg
|
||||
0|ENDBLK
|
||||
0|BLOCK|2|OPEN
|
||||
0|CIRCLE|40|1.0
|
||||
0|ENDSEC
|
||||
0|LINE|8|between
|
||||
0|SECTION|2|ENTITIES
|
||||
0|INSERT|5|80|2|DOOR|66|1
|
||||
0|ATTRIB|1|one
|
||||
0|ATTRIB|1|two
|
||||
0|SEQEND
|
||||
0|INSERT|5|81|2|DOOR
|
||||
0|SEQEND
|
||||
0|ATTRIB|1|stray
|
||||
0|ENDSEC
|
||||
0|SECTION|2|OBJECTS
|
||||
0|DICTIONARY|5|C|3|ACAD_GROUP|350|D|3|ACAD_LAYOUT|350|1A
|
||||
0|LAYOUT|5|51|330|1A|100|AcDbPlotSettings|1|page setup|10|9.0|100|AcDbLayout|1|Sheet 1|70|1|71|1|10|0.0|20|0.0|11|420.0|21|297.0|330|41
|
||||
0|LAYOUT|5|50|100|AcDbLayout|1|Model|71|0|330|40
|
||||
0|ENDSEC
|
||||
0|SECTION|2|ENTITIES
|
||||
0|LINE|5|82|67|1
|
||||
0|ENDSEC
|
||||
0|SECTION|2|ACDSDATA
|
||||
0|ACDSRECORD|90|1
|
||||
0|ENDSEC
|
||||
0|EOF
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn the_sections_of_a_drawing() {
|
||||
let t = tags(DRAWING);
|
||||
let d = Document::read(&t);
|
||||
assert_eq!(d.sections, 7);
|
||||
assert_eq!(d.header.vars.len(), 5);
|
||||
assert_eq!(d.header.acadver().as_deref(), Some("AC1027"));
|
||||
assert_eq!(d.header.version(), Some(Version::R2013));
|
||||
assert_eq!(d.header.insunits(), 4);
|
||||
assert_eq!(d.header.measurement(), None);
|
||||
assert_eq!(d.header.ltscale(), 1.0, "a scale of 0 is none");
|
||||
assert_eq!(d.header.lwdefault(), 25);
|
||||
assert_eq!(d.header.point("extmin"), Some([1.5, 2.5, 0.0]));
|
||||
assert_eq!(d.header.handseed(), Some(0xFFFF));
|
||||
assert_eq!(d.header.f("$NOSUCH"), None);
|
||||
// Entities of both ENTITIES sections, in order; the second INSERT takes no SEQEND.
|
||||
let kinds: Vec<&str> = d.entities.iter().map(|e| e.kind).collect();
|
||||
assert_eq!(kinds, ["INSERT", "INSERT", "LINE"]);
|
||||
assert_eq!(d.entities[0].followers.len(), 2);
|
||||
assert!(d.entities[0].seqend.is_some());
|
||||
assert!(d.entities[1].followers.is_empty() && d.entities[1].seqend.is_none());
|
||||
assert!(d.entities[2].paper_space() && !d.entities[0].paper_space());
|
||||
assert_eq!(d.other_sections.len(), 1);
|
||||
assert_eq!(d.other_sections[0].name, "ACDSDATA");
|
||||
// What was left out, by section: the LINE between sections, the LINE outside a block and the
|
||||
// VERTEX after a SEQEND, the ATTRIB after an INSERT without attributes.
|
||||
assert_eq!(d.skipped, [("(no section)", 1), ("BLOCKS", 2), ("ENTITIES", 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_hold_their_entities_up_to_endblk() {
|
||||
let t = tags(DRAWING);
|
||||
let d = Document::read(&t);
|
||||
let names: Vec<&str> = d.blocks.iter().map(|b| b.name).collect();
|
||||
assert_eq!(names, ["DOOR", "NOTABLE", "OPEN"]);
|
||||
let door = d.block("door").expect("found in any case");
|
||||
assert_eq!(door.base(), [1.0, 2.0, 3.0]);
|
||||
assert_eq!(door.description().as_deref(), Some("A door"));
|
||||
assert!(!door.is_xref());
|
||||
assert_eq!(door.block_record(), Some(0x42));
|
||||
assert_eq!(door.entities.len(), 1);
|
||||
assert_eq!(door.entities[0].followers.len(), 2);
|
||||
assert_eq!(door.entities[0].seqend.map(|s| s.handle()), Some(Some(0x72)));
|
||||
assert_eq!(door.end.map(|e| e.handle()), Some(Some(0x73)));
|
||||
let xref = &d.blocks[1];
|
||||
assert!(xref.is_xref());
|
||||
assert_eq!(xref.xref_path().as_deref(), Some("other.dwg"));
|
||||
// A block the section ends in keeps what it has.
|
||||
assert_eq!(d.blocks[2].entities.len(), 1);
|
||||
assert!(d.blocks[2].end.is_none());
|
||||
// Block record handle to name: the table's, and a BLOCK's own 330 where the table has none.
|
||||
let by_record = d.block_names();
|
||||
assert_eq!(by_record.get(&0x42).copied(), Some("DOOR"));
|
||||
assert_eq!(by_record.get(&0x99).copied(), Some("NOTABLE"));
|
||||
assert_eq!(d.table_names("STYLE").get(&0x20).copied(), Some("Notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers() {
|
||||
let t = tags(DRAWING);
|
||||
let layers = Document::read(&t).layers();
|
||||
assert_eq!(layers.len(), 2, "a layer without a name is no layer");
|
||||
let w = &layers[0];
|
||||
assert_eq!((w.handle, w.name.as_str(), w.color), (Some(0x10), "Walls", Color::Index(3)));
|
||||
assert!(!w.on && w.frozen && w.locked && !w.vp_freeze_new && !w.plot);
|
||||
assert_eq!((w.linetype.as_str(), w.lineweight, w.plot_style), ("DASHED", Lineweight::Hundredths(35), Some(0xF)));
|
||||
assert_eq!(w.transparency, Transparency::Alpha(127));
|
||||
assert_eq!(w.description.as_deref(), Some("outer walls"));
|
||||
let tc = &layers[1];
|
||||
assert_eq!(tc.color, Color::Rgb([255, 0, 255]));
|
||||
assert!(tc.on && tc.plot && !tc.frozen);
|
||||
assert_eq!((tc.linetype.as_str(), tc.lineweight, tc.transparency), ("Continuous", Lineweight::Default, Transparency::ByLayer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linetypes_with_text_elements() {
|
||||
let t = tags(DRAWING);
|
||||
let lt = Document::read(&t).linetypes();
|
||||
let gas = <[0];
|
||||
assert_eq!((gas.name.as_str(), gas.description.as_str(), gas.length), ("GAS", "Gas line", 1.0));
|
||||
let lengths: Vec<f64> = gas.pattern.iter().map(|e| e.length).collect();
|
||||
assert_eq!(lengths, [0.5, -0.2, -0.25]);
|
||||
let text = &gas.pattern[1];
|
||||
assert_eq!(text.text.as_deref(), Some("GAS"));
|
||||
assert_eq!((text.style, text.scale, text.rotation, text.offset), (Some(0x20), 0.1, 15.0, [-0.1, -0.05]));
|
||||
assert!(gas.pattern[0].text.is_none() && gas.pattern[0].shape.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_and_their_fonts() {
|
||||
let t = tags(DRAWING);
|
||||
let st = Document::read(&t).text_styles();
|
||||
assert_eq!(st.len(), 4, "the unnamed shape file entry is kept");
|
||||
let notes = &st[0];
|
||||
assert_eq!((notes.height, notes.width_factor, notes.oblique), (2.5, 0.8, 15.0));
|
||||
assert!(notes.vertical && notes.backwards && notes.upside_down && !notes.shape_file && !notes.annotative);
|
||||
assert_eq!(notes.family().as_deref(), Some("arial"));
|
||||
assert_eq!(st[1].family().as_deref(), Some("Noto Sans"), "the typeface of the ACAD extended data");
|
||||
assert!(st[2].shape_file && st[2].name.is_empty());
|
||||
assert_eq!(st[3].family(), None, "a CAD font has no family");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_styles_name_their_variables() {
|
||||
let t = tags(DRAWING);
|
||||
let ds = Document::read(&t).dim_styles();
|
||||
let iso = &ds[0];
|
||||
assert_eq!((iso.handle, iso.name.as_str(), iso.flags), (Some(0x30), "ISO-25", 0), "the handle is group 105");
|
||||
let names: Vec<&str> = iso.vars.iter().map(|(n, _)| *n).collect();
|
||||
assert_eq!(names, ["DIMASZ", "DIMDSEP", "DIMTXSTY", "DIMTFILL", "DIMTFILLCLR"], "the first 70 is the flags; extended data is no variable");
|
||||
assert_eq!(iso.var("$dimasz").map(Tag::f64), Some(2.5));
|
||||
assert_eq!(iso.var("DIMTFILLCLR").map(Tag::i64), Some(5));
|
||||
assert_eq!((dim_var_name(147), dim_var_code("dimgap")), (Some("DIMGAP"), Some(147)));
|
||||
assert_eq!((dim_var_name(1), dim_var_code("DIMNOPE")), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_other_tables() {
|
||||
let t = tags(DRAWING);
|
||||
let d = Document::read(&t);
|
||||
let br = d.block_records();
|
||||
assert_eq!(br.len(), 3);
|
||||
assert_eq!((br[0].layout, br[0].explodable), (Some(0x50), true));
|
||||
assert_eq!((br[2].units, br[2].explodable, br[2].uniform_scale), (4, false, true));
|
||||
let v = &d.views()[0];
|
||||
assert!(v.paper_space);
|
||||
assert_eq!((v.height, v.width, v.center, v.target, v.direction, v.twist), (10.0, 20.0, [1.0, 2.0], [5.0, 6.0, 7.0], [0.0, 0.0, 1.0], 30.0));
|
||||
let u = &d.ucss()[0];
|
||||
assert_eq!((u.origin, u.x_axis, u.y_axis), ([1.0, 2.0, 3.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]));
|
||||
let vp = &d.vports()[0];
|
||||
assert_eq!(
|
||||
(vp.name.as_str(), vp.max, vp.center, vp.snap_spacing, vp.grid_spacing),
|
||||
("*ACTIVE", [1.0, 1.0], Some([4.0, 5.0]), Some([0.5, 0.5]), None)
|
||||
);
|
||||
assert_eq!((vp.height, vp.aspect, vp.twist, vp.snap_angle), (9.0, Some(1.5), 10.0, None));
|
||||
assert_eq!(d.app_ids()[0].name, "ACAD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_and_dictionaries() {
|
||||
let t = tags(DRAWING);
|
||||
let d = Document::read(&t);
|
||||
let layouts = d.layouts();
|
||||
assert_eq!(layouts.len(), 2);
|
||||
let sheet = &layouts[0];
|
||||
// The name and limits are the layout's own, not the plot settings' 1 and 10 before them; the
|
||||
// block record is the 330 among its own groups, not its dictionary's.
|
||||
assert_eq!((sheet.name.as_str(), sheet.tab_order, sheet.block_record), ("Sheet 1", 1, Some(0x41)));
|
||||
assert_eq!(sheet.limits, Some(([0.0, 0.0], [420.0, 297.0])));
|
||||
assert_eq!(d.block_names().get(&0x41).copied(), Some("*Paper_Space"));
|
||||
assert_eq!((layouts[1].name.as_str(), layouts[1].limits), ("Model", None));
|
||||
let root = d.root_dictionary().expect("the first dictionary");
|
||||
assert_eq!(root.get("acad_layout"), Some(0x1A));
|
||||
assert_eq!(root.entries.len(), 2);
|
||||
assert_eq!(d.dictionary(0xC), Some(root));
|
||||
assert_eq!(d.dictionary(0xD), None);
|
||||
assert_eq!(Dictionary::default().get("x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_points_keep_their_own_coordinates() {
|
||||
// The second point has no z: it must not take the third's.
|
||||
let t = tags("0|LEADER|10|1.0|20|2.0|30|3.0|10|4.0|20|5.0|10|6.0|20|7.0|30|8.0");
|
||||
let g = Groups(&t);
|
||||
assert_eq!(g.points(10), [[1.0, 2.0, 3.0], [4.0, 5.0, 0.0], [6.0, 7.0, 8.0]]);
|
||||
assert_eq!(g.point(10), [1.0, 2.0, 3.0]);
|
||||
assert_eq!(g.point_opt(11), None);
|
||||
assert_eq!(g.all_f(20), [2.0, 5.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subclass_groups_and_application_groups() {
|
||||
let t = tags("0|X|5|1F|102|{ACAD_REACTORS|330|AA|102|}|330|BB|100|AcDbEntity|8|L|100|AcDbLine|10|1.0|100|AcDbOther|10|2.0");
|
||||
let g = Groups(&t[1..]);
|
||||
assert_eq!(g.subclass("AcDbLine").f(10), Some(1.0));
|
||||
assert_eq!(g.subclass("AcDbOther").f(10), Some(2.0));
|
||||
assert!(g.subclass("AcDbNone").0.is_empty());
|
||||
// The owner is the 330 outside the reactors group.
|
||||
let d = craft_dxf::model::records(&t);
|
||||
assert_eq!((d[0].handle(), d[0].owner()), (Some(0x1F), Some(0xBB)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_lineweights_and_transparency() {
|
||||
assert_eq!(Color::from_index(0), Color::ByBlock);
|
||||
assert_eq!(Color::from_index(256), Color::ByLayer);
|
||||
assert_eq!(Color::from_index(-5), Color::ByLayer);
|
||||
assert_eq!(Color::from_index(255), Color::Index(255));
|
||||
assert_eq!(Color::from_true(0x7F00_FF80), Color::Rgb([0, 255, 128]), "the top byte is not a channel");
|
||||
let t = tags("0|LINE|62|1|420|255");
|
||||
assert_eq!(Color::of(&Groups(&t)), Color::Rgb([0, 0, 255]), "a true colour wins over its index");
|
||||
for v in [-1, -2, -3, 0, 25, 211] {
|
||||
assert_eq!(Lineweight::from_dxf(v).to_dxf(), v);
|
||||
}
|
||||
assert_eq!(Lineweight::from_dxf(-9), Lineweight::ByLayer);
|
||||
assert_eq!(Lineweight::from_dxf(1 << 40), Lineweight::ByLayer);
|
||||
assert_eq!(Transparency::from_dxf(0x0100_0000), Transparency::ByBlock);
|
||||
assert_eq!(Transparency::from_dxf(0x0200_0080), Transparency::Alpha(128));
|
||||
assert_eq!(Transparency::from_dxf(0), Transparency::ByLayer);
|
||||
assert_eq!(Transparency::Alpha(128).to_dxf(), 0x0200_0080);
|
||||
assert_eq!(aci::rgb(5), [0, 0, 255]);
|
||||
assert_eq!((parse_handle(" 1f "), parse_handle("xyz"), parse_handle("")), (Some(31), None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_coordinates() {
|
||||
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-12);
|
||||
assert!(close(ocs_to_wcs([0.0, 0.0, 1.0], [1.0, 2.0, 3.0]), [1.0, 2.0, 3.0]));
|
||||
// The Z axis turned over: x runs the other way and the elevation is below.
|
||||
assert!(close(ocs_to_wcs([0.0, 0.0, -1.0], [1.0, 2.0, 3.0]), [-1.0, 2.0, -3.0]));
|
||||
// A plane facing +X: its X axis is world Y, its Y axis world Z.
|
||||
assert!(close(ocs_to_wcs([1.0, 0.0, 0.0], [1.0, 2.0, 3.0]), [3.0, 1.0, 2.0]));
|
||||
// No direction at all is the world system, not a division by zero.
|
||||
assert!(close(ocs_to_wcs([0.0, 0.0, 0.0], [1.0, 2.0, 3.0]), [1.0, 2.0, 3.0]));
|
||||
assert!(close(ocs_to_wcs([f64::NAN, 0.0, 1.0], [1.0, 2.0, 3.0]), [1.0, 2.0, 3.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_input_reads_as_what_it_holds() {
|
||||
for text in [
|
||||
"0|SECTION",
|
||||
"0|SECTION|2|BLOCKS|0|BLOCK",
|
||||
"0|SECTION|2|TABLES|0|LAYER|0|LTYPE|0|STYLE|0|DIMSTYLE|0|VIEW|0|UCS|0|VPORT|0|APPID|0|BLOCK_RECORD",
|
||||
"9|$X",
|
||||
"0|SECTION|2|OBJECTS|0|LAYOUT|0|DICTIONARY|3|a|3|b|350|zz|350|1",
|
||||
] {
|
||||
let t = tags(text);
|
||||
let d = Document::read(&t);
|
||||
let _ = (d.layers(), d.linetypes(), d.text_styles(), d.dim_styles(), d.block_records(), d.views(), d.ucss(), d.vports(), d.app_ids());
|
||||
let _ = (d.layouts(), d.root_dictionary(), d.block_names(), d.header.version());
|
||||
assert!(d.layers().is_empty() && d.layouts().is_empty());
|
||||
}
|
||||
let none: Vec<Tag> = Vec::new();
|
||||
assert_eq!(Document::read(&none).sections, 0);
|
||||
}
|
||||
Reference in new issue
Block a user