diff --git a/README.md b/README.md index 0b71e7875..b9b53325e 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Option { + 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 { + 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 { + 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 { + self.first(code).map(Tag::str) + } + /// The first `code` as a name: trimmed, and `None` when empty. + pub fn name(&self, code: i32) -> Option { + 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 { + 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 { + self.f(code).map(|_| self.point(code)) + } + /// Every `code` as a number, in order. + pub fn all_f(&self, code: i32) -> Vec { + 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 { + let mut out: Vec = 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 `, 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 `, `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 { + 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 { + 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]] +} diff --git a/crates/dxf/src/model/aci.rs b/crates/dxf/src/model/aci.rs new file mode 100644 index 000000000..684693e22 --- /dev/null +++ b/crates/dxf/src/model/aci.rs @@ -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) -> u8 { + let dist = |c: [u8; 3]| color.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::(); + 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); + } +} diff --git a/crates/dxf/src/model/document.rs b/crates/dxf/src/model/document.rs new file mode 100644 index 000000000..5022b4417 --- /dev/null +++ b/crates/dxf/src/model/document.rs @@ -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 { + 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 { + 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> { + let starts: Vec = 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>, + pub seqend: Option>, +} + +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>, 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 = 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> { + 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 { + self.var(name)?.0.first().map(Tag::f64) + } + /// A variable of one whole number. + pub fn i(&self, name: &str) -> Option { + self.var(name)?.0.first().map(Tag::i64) + } + /// A variable of one string or handle. + pub fn s(&self, name: &str) -> Option { + self.var(name)?.0.first().map(Tag::str) + } + /// A point variable (groups 10/20/30). + pub fn point(&self, name: &str) -> Option { + self.var(name)?.point_opt(10) + } + /// `$ACADVER` (`AC1032`...), trimmed. + pub fn acadver(&self) -> Option { + 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::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 { + 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 { + 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>, + /// The ENDBLK record; `None` when the section ended first. + pub end: Option>, +} + +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 { + self.groups().s(1).filter(|p| !p.is_empty()) + } + /// Group 4. + pub fn description(&self) -> Option { + 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 { + Record { kind: "BLOCK", tags: self.tags }.owner() + } +} + +/// A LAYOUT object. +#[derive(Clone, Debug, PartialEq)] +pub struct Layout { + pub handle: Option, + /// 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, + /// 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 { + 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, + pub entries: Vec<(String, u64)>, +} + +impl Dictionary { + pub fn read(r: &Record<'_>) -> Dictionary { + let mut entries = Vec::new(); + let mut name: Option = 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 { + 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>, + /// 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>, + pub blocks: Vec>, + /// The ENTITIES section. A damaged file may split its entities over several: they are joined. + pub entities: Vec>, + /// The OBJECTS section. + pub objects: Vec>, + /// Every section this model has no place for (THUMBNAILIMAGE, ACDSDATA, an unknown name), + /// as it was split. + pub other_sections: Vec>, + /// 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(&self, kind: &str, read: impl Fn(&Record<'a>) -> Option) -> Vec { + 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 { + self.table("LAYER", Layer::read) + } + pub fn linetypes(&self) -> Vec { + self.table("LTYPE", Linetype::read) + } + pub fn text_styles(&self) -> Vec { + self.table("STYLE", TextStyle::read) + } + pub fn dim_styles(&self) -> Vec> { + self.table("DIMSTYLE", DimStyle::read) + } + pub fn block_records(&self) -> Vec { + self.table("BLOCK_RECORD", BlockRecord::read) + } + pub fn views(&self) -> Vec { + self.table("VIEW", View::read) + } + pub fn ucss(&self) -> Vec { + self.table("UCS", Ucs::read) + } + pub fn vports(&self) -> Vec { + self.table("VPORT", Vport::read) + } + pub fn app_ids(&self) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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 { + self.objects.iter().find(|r| r.kind == "DICTIONARY").map(Dictionary::read) + } +} diff --git a/crates/dxf/src/model/tables.rs b/crates/dxf/src/model/tables.rs new file mode 100644 index 000000000..0cbe819a9 --- /dev/null +++ b/crates/dxf/src/model/tables.rs @@ -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, + 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, + /// The `AcCmTransparency` extended data. + pub transparency: Transparency, + /// The description in the `AcAecLayerStandard` extended data (its second string). + pub description: Option, +} + +impl Layer { + /// `None` for a record without a name. + pub fn read(r: &Record<'_>) -> Option { + 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, + /// Group 75, when group 74 bit 4 says the element draws a shape. + pub shape: Option, + /// Group 340: the STYLE of the text or shape. + pub style: Option, + /// 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, + 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, +} + +impl Linetype { + pub fn read(r: &Record<'_>) -> Option { + let g = r.groups(); + let mut pattern: Vec = 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, + /// 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, + /// 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 { + 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 { + 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 { + 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, + 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> { + 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, + pub name: String, + /// Group 340: the LAYOUT of a model or paper space block. + pub layout: Option, + /// 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 { + 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, + 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 { + 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, + pub name: String, + pub origin: Point, + pub x_axis: Point, + pub y_axis: Point, +} + +impl Ucs { + pub fn read(r: &Record<'_>) -> Option { + 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, + 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, + /// Group 50: the snap rotation, in degrees. + pub snap_angle: Option, + /// Group 51: the twist, in degrees. + pub twist: f64, +} + +impl Vport { + pub fn read(r: &Record<'_>) -> Option { + 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, + pub name: String, +} + +impl AppId { + pub fn read(r: &Record<'_>) -> Option { + Some(AppId { handle: r.handle(), name: r.groups().name(2)? }) + } +} diff --git a/crates/dxf/tests/model.rs b/crates/dxf/tests/model.rs new file mode 100644 index 000000000..f2429ce7b --- /dev/null +++ b/crates/dxf/tests/model.rs @@ -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 { + 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 = 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 = Vec::new(); + assert_eq!(Document::read(&none).sections, 0); +}