DXF round trip: DIMSTYLE, DSTYLE overrides, DIMASSOC, constraints, tables, text style flags

- DIMSTYLE: every DimStyle field with its standard group code (DIMBLK/1/2 as
  BLOCK_RECORD handles 342/343/344 with DIMSAH, DIMTXSTY 340, DIMDSEP, DIMCLRx, ...);
  arrowhead blocks generated from our own geometry when no user block exists, and
  not re-imported as user blocks on read.
- Per-dimension overrides as ACAD "DSTYLE" xdata (1070 code + typed value).
- Associativity: standard ACAD_DIMASSOC extension dictionary + DIMASSOC objects
  (AcDbOsnapPointRef) for linear/aligned/ordinate extension origins, plus exact
  links in CADCRAFT xdata; reader infers links from DIMASSOC for foreign files.
- Constraints/parameters: JSON in an XRECORD under the named-object-dictionary
  key CADCRAFT_CONSTRAINTS (250-char chunks, backslashes escaped so no \U+).
- ACAD_TABLE write/read (sizes, cell text, merges) with *T display block,
  TABLESTYLE objects and CADCRAFT xdata for title/header/text height/style.
- STYLE: vertical (70), backwards/upside-down (71), AcadAnnotative xdata.
- Reader: owner 330 taken outside 102 groups; hostile-input caps and tests.
- Validated: ezdxf strict readfile + audit (0 errors/0 fixes) and resave;
  DXF->DWG->DXF via acadrust keeps all of it except TABLESTYLE objects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 02:05:03 -04:00
1 parent f9aa1b279a
commit d2ce7e6fef
7 files changed
+1813 -70

No files matched your search

Generated
+1
View File
@@ -644,6 +644,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.8.9",
"serde",
"serde_json",
"thiserror 2.0.21",
]
+1
View File
@@ -17,4 +17,5 @@ cadcraft-dwg = { workspace = true }
cadcraft-render = { workspace = true }
thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
miniz_oxide = "0.8"
+425
View File
@@ -0,0 +1,425 @@
//! DXF data shared by the reader and the writer beyond plain entity geometry:
//! dimension-variable group codes (DIMSTYLE records and the `ACAD` "DSTYLE" override xdata,
//! both per the DXF Reference), arrowhead block names, extended-data helpers and the
//! CADCraft-owned payloads (exact associativity, table flags, parametric constraints).
//!
//! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the
//! named-object-dictionary entry `CADCRAFT_CONSTRAINTS` (an XRECORD). Other readers keep or
//! ignore both.
use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, Parametric};
use cadcraft_dxf::Tag;
use cadcraft_render::Arrowhead;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Registered application name of CADCraft's extended data.
pub(crate) const APP: &str = "CADCRAFT";
/// Named-object-dictionary key of the constraint XRECORD.
pub(crate) const CONSTRAINTS_KEY: &str = "CADCRAFT_CONSTRAINTS";
/// Caps for hostile input.
pub(crate) const MAX_XDATA_ITEMS: usize = 4096;
pub(crate) const MAX_PAYLOAD: usize = 64 << 20;
pub(crate) const MAX_CONSTRAINTS: usize = 1_000_000;
/// How a dimension variable is stored.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum K {
Real,
Int,
Bool,
Str,
/// ACI colour number (0 ByBlock, 256 ByLayer).
Color,
/// A character stored as its code (DIMDSEP).
Char,
/// Handle of a STYLE record (DIMTXSTY).
TextStyle,
/// Handle of an arrowhead BLOCK_RECORD (DIMBLK, DIMBLK1, DIMBLK2).
Block,
}
/// [`DimStyle`] field (camelCase) → DIMSTYLE group code (DXF Reference, DIMSTYLE table). The
/// same codes identify variables in the DSTYLE override xdata.
pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[
("post", 3, K::Str),
("altPost", 4, K::Str),
("scale", 40, K::Real),
("arrowSize", 41, K::Real),
("extOffset", 42, K::Real),
("baselineSpacing", 43, K::Real),
("extExtend", 44, K::Real),
("round", 45, K::Real),
("dimLineExtend", 46, K::Real),
("tolPlus", 47, K::Real),
("tolMinus", 48, K::Real),
("textHeight", 140, K::Real),
("centerMark", 141, K::Real),
("tickSize", 142, K::Real),
("altFactor", 143, K::Real),
("linearFactor", 144, K::Real),
("tolScale", 146, K::Real),
("textGap", 147, K::Real),
("tolerance", 71, K::Bool),
("limits", 72, K::Bool),
("textInsideHorizontal", 73, K::Bool),
("textOutsideHorizontal", 74, K::Bool),
("suppressExt1", 75, K::Bool),
("suppressExt2", 76, K::Bool),
("textAbove", 77, K::Int),
("zeroSuppression", 78, K::Int),
("alt", 170, K::Bool),
("altDecimals", 171, K::Int),
("dimLineColor", 176, K::Color),
("extLineColor", 177, K::Color),
("textColor", 178, K::Color),
("angularDecimals", 179, K::Int),
("decimals", 271, K::Int),
("tolDecimals", 272, K::Int),
("angularUnit", 275, K::Int),
("fractionFormat", 276, K::Int),
("linearUnit", 277, K::Int),
("decimalSeparator", 278, K::Char),
("textJust", 280, K::Int),
("textStyle", 340, K::TextStyle),
("arrowBlock", 342, K::Block),
("arrowBlock1", 343, K::Block),
("arrowBlock2", 344, K::Block),
];
/// DIMSAH: separate arrow blocks in use.
pub(crate) const DIMSAH: i32 = 173;
pub(crate) fn dim_code(field: &str) -> Option<(i32, K)> {
DIM_CODES.iter().find(|(f, _, _)| *f == field).map(|(_, c, k)| (*c, *k))
}
pub(crate) fn dim_field(code: i32) -> Option<(&'static str, K)> {
DIM_CODES.iter().find(|(_, c, _)| *c == code).map(|(f, _, k)| (*f, *k))
}
/// The canonical JSON of a dimension-variable value (as a serialised [`DimStyle`] holds it),
/// or `None` when the value is unusable for the field.
pub(crate) fn canonical(field: &str, v: &Value) -> Option<Value> {
let field = DimStyle::field_name(field)?;
let mut st = DimStyle::default();
let mut m = serde_json::Map::new();
m.insert(field.to_string(), v.clone());
if !st.apply_fields(&m).is_empty() {
return None;
}
serde_json::to_value(&st).ok()?.get(field).cloned()
}
/// A dimension variable encoded for a group: real, integer, string or handle.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum DimVal {
Real(f64),
Int(i64),
Str(String),
Handle(String),
}
/// Encode a canonical field value. `style` / `block` map names to handles.
pub(crate) fn encode(kind: K, v: &Value, style: &dyn Fn(&str) -> Option<String>, block: &dyn Fn(&str) -> Option<String>) -> Option<DimVal> {
Some(match kind {
K::Real => DimVal::Real(v.as_f64().filter(|x| x.is_finite())?),
K::Int => DimVal::Int(v.as_i64()?),
K::Bool => DimVal::Int(i64::from(v.as_bool()?)),
K::Str => DimVal::Str(v.as_str()?.to_string()),
K::Color => DimVal::Int(i64::from(serde_json::from_value::<Color>(v.clone()).ok()?.to_aci())),
K::Char => DimVal::Int(i64::from(u32::from(v.as_str()?.chars().next().unwrap_or('.')))),
K::TextStyle => DimVal::Handle(style(v.as_str()?)?),
K::Block => {
let name = v.as_str()?;
DimVal::Handle(if name.trim().is_empty() { "0".to_string() } else { block(name)? })
}
})
}
/// Decode a group value into the JSON a [`DimStyle`] field takes. `styles` / `blocks` map
/// upper-case handles to names.
pub(crate) fn decode(kind: K, t: &Tag, styles: &HashMap<String, String>, blocks: &HashMap<String, String>) -> Option<Value> {
Some(match kind {
K::Real => {
let x = t.f64();
if !x.is_finite() {
return None;
}
serde_json::json!(x)
}
K::Int => serde_json::json!(t.i64()),
K::Bool => Value::Bool(t.i64() != 0),
K::Str => Value::String(t.str()),
K::Color => serde_json::to_value(Color::from_aci(i16::try_from(t.i64()).ok()?)).ok()?,
K::Char => Value::String(char::from_u32(u32::try_from(t.i64()).ok()?).filter(|c| !c.is_control())?.to_string()),
K::TextStyle => Value::String(styles.get(&t.str().trim().to_ascii_uppercase())?.clone()),
K::Block => {
let h = t.str().trim().to_ascii_uppercase();
if h.is_empty() || h == "0" { Value::String(String::new()) } else { Value::String(blocks.get(&h)?.clone()) }
}
})
}
/// Block names of the arrowheads (the DIMBLK names of the DXF Reference); geometry is ours.
pub(crate) const ARROWS: &[(Arrowhead, &str)] = &[
(Arrowhead::ClosedFilled, "_ClosedFilled"),
(Arrowhead::ClosedBlank, "_ClosedBlank"),
(Arrowhead::Closed, "_Closed"),
(Arrowhead::Dot, "_Dot"),
(Arrowhead::DotSmall, "_DotSmall"),
(Arrowhead::DotBlank, "_DotBlank"),
(Arrowhead::Oblique, "_Oblique"),
(Arrowhead::ArchTick, "_ArchTick"),
(Arrowhead::Open, "_Open"),
(Arrowhead::Open90, "_Open90"),
(Arrowhead::Open30, "_Open30"),
(Arrowhead::Origin, "_Origin"),
(Arrowhead::Origin2, "_Origin2"),
(Arrowhead::Small, "_Small"),
(Arrowhead::BoxFilled, "_BoxFilled"),
(Arrowhead::BoxBlank, "_BoxBlank"),
(Arrowhead::DatumFilled, "_DatumFilled"),
(Arrowhead::DatumBlank, "_DatumBlank"),
(Arrowhead::Integral, "_Integral"),
(Arrowhead::None, "_None"),
];
/// True for a standard arrowhead block name (case-insensitive).
pub(crate) fn is_standard_arrow(name: &str) -> bool {
ARROWS.iter().any(|(_, n)| n.eq_ignore_ascii_case(name))
}
/// The block an arrow name is written as when no user block has that name: names starting
/// with `_` are kept (block names are case-insensitive), other recognised names become the
/// standard name; unrecognised names (which draw as closed filled) give `None`.
pub(crate) fn arrow_block_name(name: &str) -> Option<(String, Arrowhead)> {
let n = name.trim();
if n.is_empty() {
return None;
}
let kind = Arrowhead::parse(n);
let norm: String = n.trim_start_matches('_').chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
if kind == Arrowhead::ClosedFilled && !matches!(norm.as_str(), "closedfilled" | "filled") {
return None;
}
let std_name = ARROWS.iter().find(|(k, _)| *k == kind).map(|(_, s)| s.to_string())?;
let keep = n.starts_with('_') && n.chars().skip(1).all(|c| c.is_ascii_alphanumeric());
Some((if keep { n.to_string() } else { std_name }, kind))
}
/// The extended data of one application: the groups after its `1001` up to the next `1001`.
pub(crate) fn xdata<'a>(tags: &'a [Tag], app: &str) -> &'a [Tag] {
let Some(start) = tags.iter().position(|t| t.code == 1001 && t.str().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(crate) fn xdata_list<'a>(x: &'a [Tag], name: &str) -> &'a [Tag] {
let Some(at) = x.iter().position(|t| t.code == 1000 && t.str().trim().eq_ignore_ascii_case(name)) else { return &[] };
let Some(open) = x.get(at + 1).filter(|t| t.code == 1002 && t.str().trim() == "{") else { return &[] };
let _ = open;
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().trim() {
"{" => depth += 1,
"}" if depth == 0 => return body.get(..i).unwrap_or(&[]),
"}" => depth -= 1,
_ => {}
}
}
}
body
}
/// Owner handle of a record: the first 330 outside `102 {…}` groups.
pub(crate) fn owner(tags: &[Tag]) -> Option<String> {
let mut in_group = false;
for t in tags {
match t.code {
102 => in_group = t.str().trim_start().starts_with('{'),
330 if !in_group => return Some(t.str()),
c if c >= 1000 => return None,
_ => {}
}
}
None
}
/// Per-dimension overrides from the `ACAD` "DSTYLE" xdata: `1070 <code>` followed by the value.
pub(crate) fn read_dstyle(tags: &[Tag], styles: &HashMap<String, String>, blocks: &HashMap<String, String>) -> serde_json::Map<String, Value> {
let list = xdata_list(xdata(tags, "ACAD"), "DSTYLE");
let mut out = serde_json::Map::new();
let mut it = list.iter().take(MAX_XDATA_ITEMS * 2);
while let Some(t) = it.next() {
if t.code != 1070 {
continue;
}
let code = t.i64();
let Some(v) = it.next() else { break };
let Some((field, kind)) = i32::try_from(code).ok().and_then(dim_field) else { continue };
if let Some(val) = decode(kind, v, styles, blocks).and_then(|j| canonical(field, &j)) {
out.insert(field.to_string(), val);
}
}
out
}
/// Snap kinds in CADCraft's ASSOC xdata.
fn snap_code(s: &AssocSnap) -> i64 {
match s {
AssocSnap::Start => 0,
AssocSnap::End => 1,
AssocSnap::Mid => 2,
AssocSnap::Center => 3,
AssocSnap::OnCircle { .. } => 4,
AssocSnap::Intersection { .. } => 5,
AssocSnap::Vertex { .. } => 6,
}
}
/// Exact associativity as CADCraft xdata groups: per link `1000 point`, `1005 object`,
/// `1070 snap` and the snap's argument (`1040 angle`, `1005 other object`, `1071 vertex`).
pub(crate) fn assoc_xdata(assoc: &[DimAssoc]) -> Vec<Tag> {
let mut v = vec![Tag::s(1000, "ASSOC"), Tag::s(1002, "{")];
for a in assoc {
v.push(Tag::s(1002, "{"));
v.push(Tag::s(1000, a.point.clone()));
v.push(Tag::s(1005, a.handle.hex()));
v.push(Tag::i(1070, snap_code(&a.snap)));
match &a.snap {
AssocSnap::OnCircle { angle } => v.push(Tag::f(1040, *angle)),
AssocSnap::Intersection { other } => v.push(Tag::s(1005, other.hex())),
AssocSnap::Vertex { index } => v.push(Tag::i(1071, i64::try_from(*index).unwrap_or(i64::from(i32::MAX)).min(i64::from(i32::MAX)))),
_ => {}
}
v.push(Tag::s(1002, "}"));
}
v.push(Tag::s(1002, "}"));
v
}
const POINT_NAMES: [&str; 5] = ["defpt", "p13", "p14", "p15", "p16"];
/// Associativity links from CADCraft xdata; malformed links are skipped.
pub(crate) fn read_assoc(tags: &[Tag]) -> Vec<DimAssoc> {
let list = xdata_list(xdata(tags, APP), "ASSOC");
let mut out = Vec::new();
let mut i = 0;
while let Some(t) = list.get(i) {
if out.len() >= MAX_XDATA_ITEMS {
break;
}
i += 1;
if !(t.code == 1002 && t.str().trim() == "{") {
continue;
}
let end = list.get(i..).and_then(|r| r.iter().position(|x| x.code == 1002)).map(|p| i + p).unwrap_or(list.len());
let body = list.get(i..end).unwrap_or(&[]);
i = end + 1;
let point = body.iter().find(|x| x.code == 1000).map(Tag::str).unwrap_or_default();
let Some(point) = POINT_NAMES.iter().find(|p| **p == point.trim()) else { continue };
let handles: Vec<Handle> = body.iter().filter(|x| x.code == 1005).filter_map(|x| Handle::parse_hex(&x.str())).collect();
let Some(&handle) = handles.first() else { continue };
let Some(code) = body.iter().find(|x| x.code == 1070).map(Tag::i64) else { continue };
let snap = match code {
0 => AssocSnap::Start,
1 => AssocSnap::End,
2 => AssocSnap::Mid,
3 => AssocSnap::Center,
4 => match body.iter().find(|x| x.code == 1040).map(Tag::f64).filter(|a| a.is_finite()) {
Some(angle) => AssocSnap::OnCircle { angle },
None => continue,
},
5 => match handles.get(1) {
Some(&other) => AssocSnap::Intersection { other },
None => continue,
},
6 => match body.iter().find(|x| x.code == 1071).and_then(|x| usize::try_from(x.i64()).ok()) {
Some(index) => AssocSnap::Vertex { index },
None => continue,
},
_ => continue,
};
out.push(DimAssoc { point: point.to_string(), handle, snap });
}
out
}
/// The parametric payload stored in the `CADCRAFT_CONSTRAINTS` XRECORD.
#[derive(Serialize, Deserialize)]
struct Payload {
version: u32,
#[serde(default)]
constraints: Vec<Constraint>,
#[serde(default)]
parametric: Parametric,
}
/// JSON chunks (≤ 250 characters) for the constraint XRECORD, or `None` when the drawing has
/// no parametric data. Backslashes are written as `\` so no chunk contains a DXF
/// `\U+` escape.
pub(crate) fn constraint_chunks(constraints: &[Constraint], parametric: &Parametric) -> Option<Vec<String>> {
if constraints.is_empty() && *parametric == Parametric::default() {
return None;
}
let json = serde_json::to_string(&Payload { version: 1, constraints: constraints.to_vec(), parametric: parametric.clone() }).ok()?;
let mut safe = String::with_capacity(json.len());
let mut it = json.chars().peekable();
while let Some(c) = it.next() {
if c == '\\' {
match it.next() {
Some('\\') => safe.push_str("\\u005c"),
Some(n) => {
safe.push('\\');
safe.push(n);
}
None => safe.push('\\'),
}
} else {
safe.push(c);
}
}
let chars: Vec<char> = safe.chars().collect();
Some(chars.chunks(250).map(|c| c.iter().collect()).collect())
}
/// Parse the constraint XRECORD text; `None` when it is not a payload we understand.
pub(crate) fn parse_constraints(text: &str) -> Option<(Vec<Constraint>, Parametric)> {
if text.len() > MAX_PAYLOAD {
return None;
}
let p: Payload = serde_json::from_str(text).ok()?;
if p.version != 1 || p.constraints.len() > MAX_CONSTRAINTS {
return None;
}
Some((p.constraints, p.parametric))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_dimvar_has_a_code() {
for (_, field) in cadcraft_doc::DIMVARS {
assert!(dim_code(field).is_some(), "{field} has no DXF group code");
}
}
#[test]
fn arrow_names() {
assert_eq!(arrow_block_name("_DOT").map(|x| x.0).as_deref(), Some("_DOT"));
assert_eq!(arrow_block_name("Architectural tick").map(|x| x.0).as_deref(), Some("_ArchTick"));
assert!(arrow_block_name("").is_none());
assert!(arrow_block_name("nonsense").is_none());
assert!(is_standard_arrow("_dot"));
}
}
+334 -34
View File
@@ -279,6 +279,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
}
"3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }),
"HATCH" => hatch(tags, &t)?,
"ACAD_TABLE" => EntityKind::Table(acad_table(tags)),
"VIEWPORT" => EntityKind::Viewport(Viewport {
center: t.p(10),
width: t.fd(40, 1.0),
@@ -307,6 +308,71 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
Some((c, k))
}
/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's
/// title/header flags, text height and style name from xdata when present.
fn acad_table(tags: &[Tag]) -> Table {
const MAX_CELLS: usize = 1_000_000;
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0);
let tail = tags.get(start..).unwrap_or(&[]);
let first_cell = tail.iter().position(|x| x.code == 171).unwrap_or(tail.len());
let head = T(tail.get(..first_cell).unwrap_or(&[]));
let mut rows = usize::try_from(head.i(91).unwrap_or(0)).unwrap_or(0).min(10_000);
let mut cols = usize::try_from(head.i(92).unwrap_or(0)).unwrap_or(0).min(10_000);
// Large declared sizes must be backed by cell records (no allocation from a bare count).
let n_cells = tail.iter().filter(|x| x.code == 171).count();
let declared = rows.saturating_mul(cols);
if declared > MAX_CELLS || (declared > 10_000 && n_cells < declared) {
rows = 0;
cols = 0;
}
let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 };
let mut row_heights: Vec<f64> = head.all_f(141).into_iter().take(rows).map(size).collect();
row_heights.resize(rows, 0.27);
let mut col_widths: Vec<f64> = head.all_f(142).into_iter().take(cols).map(size).collect();
col_widths.resize(cols, 2.5);
// Cells, row-major, each starting at a 171 group; xdata ends the last one.
let body = tail.get(first_cell..).unwrap_or(&[]);
let body = body.get(..body.iter().position(|x| x.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]);
let mut flat: Vec<TableCell> = Vec::new();
let mut i = 0;
while let Some(t0) = body.get(i) {
if flat.len() >= rows * cols {
break;
}
i += 1;
if t0.code != 171 {
continue;
}
let end = body.get(i..).and_then(|r| r.iter().position(|x| x.code == 171)).map(|p| i + p).unwrap_or(body.len());
let cell = body.get(i..end).unwrap_or(&[]);
i = end;
let c = T(cell);
let mut text: String = cell.iter().filter(|x| x.code == 2 || x.code == 3).map(Tag::str).collect();
text.push_str(&c.s(1).unwrap_or_default());
let span = |code: i32| c.i(code).and_then(|v| u32::try_from(v).ok());
let merged = match (c.i(173).unwrap_or(0) != 0, span(176), span(175)) {
(true, Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)),
_ => None,
};
flat.push(TableCell { text, merged });
}
flat.resize(rows * cols, TableCell { text: String::new(), merged: None });
let cells: Vec<Vec<TableCell>> = if cols == 0 { vec![Vec::new(); rows] } else { flat.chunks(cols).map(<[TableCell]>::to_vec).collect() };
let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP);
let ours = x.iter().position(|t| t.code == 1000 && t.str() == "TABLE").map(|p| x.get(p + 1..).unwrap_or(&[]));
let (title, header, text_height, style) = match ours {
Some(o) => {
let flags: Vec<i64> = o.iter().filter(|t| t.code == 1070).map(Tag::i64).collect();
let h = o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|h| h.is_finite() && *h > 0.0).unwrap_or(0.18);
let style = o.iter().find(|t| t.code == 1000).map(Tag::str).unwrap_or_else(|| "Standard".into());
(flags.first().is_some_and(|v| *v != 0), flags.get(1).is_some_and(|v| *v != 0), h, style)
}
// Other writers: a first row merged across every column reads as a title.
None => (cols > 1 && cells.first().and_then(|r| r.first()).and_then(|c| c.merged) == Some((1, cols as u32)), false, 0.18, "Standard".into()),
};
Table { insert: T(tags).p(10), col_widths, row_heights, cells, style, text_height, title, header }
}
fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
let pattern = t.s(2).unwrap_or_else(|| "SOLID".into());
let solid = t.i(70).unwrap_or(0) == 1;
@@ -445,13 +511,53 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
}))
}
/// Handle maps and references gathered while reading.
#[derive(Default)]
struct Rx {
/// STYLE record handle (upper case) → text style name.
styles: HashMap<String, String>,
/// BLOCK_RECORD handle (upper case) → block name.
brs: HashMap<String, String>,
/// Upper-case names of anonymous `*T` blocks drawn by ACAD_TABLE entities.
table_blocks: std::collections::HashSet<String>,
/// Upper-case block names used by INSERTs.
inserted: std::collections::HashSet<String>,
/// Tables whose style is known only by TABLESTYLE handle: (table, style handle).
table_style_fix: Vec<(Handle, String)>,
}
/// Dimension overrides and associativity, table references: data of an entity record that
/// needs the file's handle maps.
fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &mut Rx) {
match (kind, k) {
("DIMENSION", EntityKind::Dimension(dm)) => {
dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs);
dm.assoc = crate::dxf_ext::read_assoc(tags);
}
("ACAD_TABLE", EntityKind::Table(_)) => {
let t = T(tags);
if let Some(b) = t.s(2) {
rx.table_blocks.insert(b.to_ascii_uppercase());
}
let ours = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP).iter().any(|t| t.code == 1000 && t.str() == "TABLE");
if !ours && let Some(sh) = t.s(342) {
rx.table_style_fix.push((h, sh.trim().to_ascii_uppercase()));
}
}
("INSERT", EntityKind::Insert(ins)) => {
rx.inserted.insert(ins.block.to_ascii_uppercase());
}
_ => {}
}
}
/// Parse a run of entity records (ENTITIES section or a block's body) into entities.
fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing) -> Vec<(Option<String>, bool, Entity)> {
fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx) -> Vec<(Option<String>, bool, Entity)> {
let mut out = Vec::new();
let mut i = 0;
while let Some((kind, tags)) = recs.get(i) {
let t = T(tags);
let owner = t.s(330);
let owner = crate::dxf_ext::owner(tags);
let paper = t.i(67) == Some(1);
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
let mut kindent = entity(kind, tags);
@@ -517,7 +623,7 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing) -> Vec<(Option<S
"SEQEND" | "VERTEX" | "ATTRIB" => continue,
_ => {}
}
if let Some((c, k)) = kindent {
if let Some((c, mut k)) = kindent {
let h = match handle {
Some(h) => {
d.bump_handseed(h);
@@ -525,6 +631,7 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing) -> Vec<(Option<S
}
None => d.new_handle(),
};
entity_extras(kind, tags, h, &mut k, rx);
out.push((owner, paper, Entity { handle: h, common: c, kind: k }));
}
}
@@ -571,8 +678,29 @@ fn header(tags: &[Tag], d: &mut Drawing) {
}
}
fn tables(tags: &[Tag], d: &mut Drawing, block_records: &mut HashMap<String, String>) {
for (kind, tg) in records(tags) {
/// True when a style record carries `AcadAnnotative` xdata with the annotative flag set.
fn annotative(tags: &[Tag]) -> bool {
let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, "AcadAnnotative"), "AnnotativeData");
list.iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0)
}
fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
let recs = records(tags);
// Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle.
for (kind, tg) in &recs {
let t = T(tg);
let (Some(h), Some(n)) = (t.s(5), t.s(2)) else { continue };
match kind.as_str() {
"STYLE" => {
rx.styles.insert(h.trim().to_ascii_uppercase(), n);
}
"BLOCK_RECORD" => {
rx.brs.insert(h.trim().to_ascii_uppercase(), n);
}
_ => {}
}
}
for (kind, tg) in recs {
let t = T(&tg);
match kind.as_str() {
"LAYER" => {
@@ -614,6 +742,8 @@ fn tables(tags: &[Tag], d: &mut Drawing, block_records: &mut HashMap<String, Str
if name.is_empty() {
continue;
}
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
let gen_flags = t.i(71).unwrap_or(0);
let st = TextStyle {
name: name.clone(),
font: t.s(3).unwrap_or_default(),
@@ -621,7 +751,10 @@ fn tables(tags: &[Tag], d: &mut Drawing, block_records: &mut HashMap<String, Str
height: t.fd(40, 0.0),
width_factor: t.fd(41, 1.0),
oblique: t.fd(50, 0.0).to_radians(),
..Default::default()
backwards: gen_flags & 2 != 0,
upside_down: gen_flags & 4 != 0,
vertical: t.i(70).unwrap_or(0) & 4 != 0,
annotative: annotative(&tg),
};
match d.text_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = st,
@@ -633,33 +766,22 @@ fn tables(tags: &[Tag], d: &mut Drawing, block_records: &mut HashMap<String, Str
if name.is_empty() {
continue;
}
let def = DimStyle::default();
let st = DimStyle {
name: name.clone(),
scale: t.fd(40, 1.0),
arrow_size: t.fd(41, def.arrow_size),
ext_offset: t.fd(42, def.ext_offset),
ext_extend: t.fd(44, def.ext_extend),
text_height: t.fd(140, def.text_height),
tick_size: t.fd(142, 0.0),
linear_factor: t.fd(144, 1.0),
text_gap: t.fd(147, def.text_gap),
text_above: t.i(77).unwrap_or(0) as u8,
decimals: t.i(271).unwrap_or(4).clamp(0, 8) as u8,
zero_suppression: t.i(78).unwrap_or(0) as u8,
post: t.s(3).unwrap_or_default(),
..def
};
// Every variable with a DIMSTYLE group code; values the style can't take are
// left at their defaults.
let mut fields = serde_json::Map::new();
for tag in tg.iter().take_while(|x| x.code < 1000) {
let Some((field, kind)) = crate::dxf_ext::dim_field(tag.code) else { continue };
if let Some(v) = crate::dxf_ext::decode(kind, tag, &rx.styles, &rx.brs) {
fields.insert(field.to_string(), v);
}
}
let mut st = DimStyle { name: name.clone(), annotative: annotative(&tg), ..DimStyle::default() };
st.apply_fields(&fields);
match d.dim_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(&name)) {
Some(x) => *x = st,
None => d.dim_styles.push(st),
}
}
"BLOCK_RECORD" => {
if let (Some(h), Some(n)) = (t.s(5), t.s(2)) {
block_records.insert(h.to_ascii_uppercase(), n);
}
}
_ => {}
}
}
@@ -674,14 +796,15 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
}
let mut d = Drawing::new_imperial();
d.layouts.clear();
let mut block_records: HashMap<String, String> = HashMap::new();
let mut rx = Rx::default();
let mut blocks: Vec<(String, Vec3, Vec<(Option<String>, bool, Entity)>, bool)> = Vec::new();
let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();
let mut entities = Vec::new();
let mut objs = Objects::default();
for s in &secs {
match s.name.as_str() {
"HEADER" => header(&s.tags, &mut d),
"TABLES" => tables(&s.tags, &mut d, &mut block_records),
"TABLES" => tables(&s.tags, &mut d, &mut rx),
"BLOCKS" => {
let recs = records(&s.tags);
let mut i = 0;
@@ -696,7 +819,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
j += 1;
}
let body = recs.get(i + 1..j).unwrap_or(&[]);
let ents = parse_entities(body, &mut d);
let ents = parse_entities(body, &mut d, &mut rx);
blocks.push((name, base, ents, xref));
i = j + 1;
} else {
@@ -704,9 +827,10 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
}
}
}
"ENTITIES" => entities = parse_entities(&records(&s.tags), &mut d),
"ENTITIES" => entities = parse_entities(&records(&s.tags), &mut d, &mut rx),
"OBJECTS" => {
for (k, tg) in records(&s.tags) {
objs.add(&k, &tg);
if k == "LAYOUT" {
// Fields after the AcDbLayout marker (AcDbPlotSettings also has a code 1).
let start = tg.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0);
@@ -730,7 +854,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
continue;
}
d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) });
if let Some(bname) = block_records.get(br) {
if let Some(bname) = rx.brs.get(br) {
paper_block_for.insert(bname.to_ascii_uppercase(), name.clone());
}
}
@@ -758,6 +882,11 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
}
continue;
}
// Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks
// nothing inserts.
if rx.table_blocks.contains(&up) || (crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up)) {
continue;
}
let mut b = Block::new(&name);
b.base = base;
if xref {
@@ -771,7 +900,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
// ENTITIES section: model space, or paper space when flagged (67=1).
for (owner, paper, e) in entities {
d.ensure_layer(&e.common.layer);
let owner_name = owner.and_then(|o| block_records.get(&o.to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
let owner_name = owner.and_then(|o| rx.brs.get(&o.trim().to_ascii_uppercase()).cloned()).map(|n| n.to_ascii_uppercase());
let to_paper = paper || owner_name.as_deref().is_some_and(|n| n.starts_with("*PAPER_SPACE"));
if to_paper {
let lname = owner_name.and_then(|n| paper_block_for.get(&n).cloned()).unwrap_or_else(|| first_layout.clone());
@@ -782,6 +911,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
d.model.push(e);
}
}
objs.apply(&mut d, &rx);
// Keep handles unique against the header's seed.
let seed = d.header.str("HANDSEED", "");
if let Some(h) = Handle::parse_hex(&seed) {
@@ -793,6 +923,176 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
Ok(d)
}
/// Non-graphical objects applied once every entity is placed.
#[derive(Default)]
struct Objects {
/// Dictionary entry handle (upper case) → entry name.
names: HashMap<String, String>,
/// XRECORD handle (upper case) → its groups.
xrecords: HashMap<String, Vec<Tag>>,
/// TABLESTYLE objects: (handle, groups).
table_styles: Vec<(String, Vec<Tag>)>,
/// DIMASSOC objects.
dimassocs: Vec<Vec<Tag>>,
}
const MAX_OBJECTS: usize = 1_000_000;
impl Objects {
fn add(&mut self, kind: &str, tags: &[Tag]) {
let h = T(tags).s(5).map(|h| h.trim().to_ascii_uppercase()).unwrap_or_default();
match kind {
"DICTIONARY" => {
let mut name: Option<String> = None;
for t in tags {
match t.code {
3 => name = Some(t.str()),
350 | 360 => {
if let Some(n) = name.take()
&& self.names.len() < MAX_OBJECTS
{
self.names.insert(t.str().trim().to_ascii_uppercase(), n);
}
}
_ => {}
}
}
}
"XRECORD" if self.xrecords.len() < MAX_OBJECTS => {
self.xrecords.insert(h, tags.to_vec());
}
"TABLESTYLE" if self.table_styles.len() < MAX_OBJECTS => self.table_styles.push((h, tags.to_vec())),
"DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()),
_ => {}
}
}
fn apply(self, d: &mut Drawing, rx: &Rx) {
// Table styles (named by their ACAD_TABLESTYLE dictionary entries).
for (h, tags) in &self.table_styles {
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
let t = T(tags);
let def = TableStyle::default();
let pos = |v: f64, d: f64| if v.is_finite() && v > 0.0 { v } else { d };
let st = TableStyle {
name: name.clone(),
text_height: pos(t.fd(140, def.text_height), def.text_height),
margin: t.f(40).filter(|m| m.is_finite() && *m >= 0.0).unwrap_or(def.margin),
title: t.i(280).unwrap_or(0) == 0,
header: t.i(281).unwrap_or(0) == 0,
};
match d.table_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) {
Some(x) => *x = st,
None => d.table_styles.push(st),
}
}
for (h, sh) in &rx.table_style_fix {
if let Some(name) = self.names.get(sh) {
let _ = d.modify_entity(*h, |e| {
if let EntityKind::Table(t) = &mut e.kind {
t.style = name.clone();
}
});
}
}
// Parametric constraints and parameters.
let key = self.names.iter().find(|(_, n)| n.as_str() == crate::dxf_ext::CONSTRAINTS_KEY).map(|(h, _)| h.clone());
if let Some(tags) = key.and_then(|k| self.xrecords.get(&k)) {
let mut text = String::new();
for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) {
if text.len() > crate::dxf_ext::MAX_PAYLOAD {
break;
}
text.push_str(&t.str());
}
if let Some((c, p)) = crate::dxf_ext::parse_constraints(&text) {
d.constraints = c;
d.parametric = p;
}
}
// Standard associativity, for dimensions without CADCraft's exact links.
for tags in &self.dimassocs {
dimassoc(d, tags);
}
}
}
/// Associativity from a DIMASSOC object (files from other writers).
fn dimassoc(d: &mut Drawing, tags: &[Tag]) {
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0);
let tail = tags.get(start..).unwrap_or(&[]);
let Some(dim_h) = tail.iter().find(|x| x.code == 330).and_then(|x| Handle::parse_hex(&x.str())) else { return };
let Some(EntityKind::Dimension(dm)) = d.entity(dim_h).map(|e| &e.kind) else { return };
if !dm.assoc.is_empty() {
return;
}
let slots: &[&str] = match dm.kind {
DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"],
DimKind::Ordinate { .. } => &["p13"],
_ => return,
};
let flags = T(tail).i(90).unwrap_or(0);
let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect();
// Point references start at each `1 AcDbOsnapPointRef`.
let starts: Vec<usize> = tail.iter().enumerate().filter(|(_, x)| x.code == 1).map(|(i, _)| i).take(8).collect();
let mut links = Vec::new();
for (k, s) in starts.iter().enumerate() {
let Some(point) = used.get(k) else { break };
let end = starts.get(k + 1).copied().unwrap_or(tail.len());
let r = T(tail.get(s + 1..end).unwrap_or(&[]));
let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue };
let other = r.s(332).and_then(|h| Handle::parse_hex(&h));
let at = r.p(10).xy();
if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at) {
links.push(DimAssoc { point: (*point).to_string(), handle: main, snap });
}
}
if !links.is_empty() {
let _ = d.modify_entity(dim_h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.assoc = links;
}
});
}
}
/// A CADCraft snap from a DIMASSOC object snap type and point.
fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option<Handle>, at: Vec2) -> Option<AssocSnap> {
let e = d.entity(main)?;
Some(match osnap {
1 | 13 => match &e.kind {
EntityKind::Line(l) => {
if osnap == 13 || l.a.xy().dist(at) <= l.b.xy().dist(at) {
AssocSnap::Start
} else {
AssocSnap::End
}
}
EntityKind::Arc(a) => {
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
if osnap == 13 || g.start_point().dist(at) <= g.end_point().dist(at) { AssocSnap::Start } else { AssocSnap::End }
}
EntityKind::LwPolyline(p) => {
let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?;
AssocSnap::Vertex { index }
}
EntityKind::Point(_) => AssocSnap::Start,
_ => return None,
},
2 => AssocSnap::Mid,
3 | 4 => AssocSnap::Center,
5 | 10 => match &e.kind {
EntityKind::Circle(Circle { center, .. }) | EntityKind::Arc(Arc { center, .. }) => {
let v = at - center.xy();
AssocSnap::OnCircle { angle: v.y.atan2(v.x) }
}
_ => return None,
},
6 => AssocSnap::Intersection { other: other.filter(|o| d.entity(*o).is_some())? },
_ => return None,
})
}
/// Page setup from a LAYOUT object's AcDbPlotSettings fields (sizes and margins in mm).
fn page_setup(t: &T) -> PageSetup {
let mut p = PageSetup::default();
+574 -36
View File
@@ -1,10 +1,14 @@
//! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000).
use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_dxf::Tag;
use cadcraft_geom::{Vec2, Vec3};
use crate::dxf_ext::{self, DimVal, K};
struct W {
t: Vec<Tag>,
next: u64,
@@ -34,6 +38,134 @@ impl W {
self.next += 1;
h
}
/// A `102 {NAME` … `102 }` group of handles.
fn group(&mut self, name: &str, code: i32, handles: &[&str]) {
self.s(102, format!("{{{name}"));
for h in handles {
self.s(code, *h);
}
self.s(102, "}");
}
/// A dimension variable as a group (DIMSTYLE records) or as an xdata value (DSTYLE).
fn dimval(&mut self, code: i32, v: DimVal, xdata: bool) {
match v {
DimVal::Real(x) => self.f(if xdata { 1040 } else { code }, x),
DimVal::Int(i) => self.i(if xdata { 1070 } else { code }, i),
DimVal::Str(t) => {
if xdata || !t.is_empty() {
self.s(if xdata { 1000 } else { code }, t);
}
}
DimVal::Handle(h) => {
if xdata {
self.s(1005, h);
} else if h != "0" {
self.s(code, h);
}
}
}
}
fn xdata(&mut self, tags: Vec<Tag>) {
self.t.extend(tags);
}
}
/// The `DIMASSOC` object of an associative dimension (stored in the dimension's extension
/// dictionary under `ACAD_DIMASSOC`).
struct AssocObj {
xdict: String,
handle: String,
/// Associativity flag (bit per point reference).
flags: i64,
refs: Vec<OsnapRef>,
}
/// An `AcDbOsnapPointRef` of a DIMASSOC object.
struct OsnapRef {
osnap: i64,
main: Handle,
param: f64,
point: Vec3,
other: Option<Handle>,
}
/// Handles and names the entity writer needs from the rest of the file.
#[derive(Default)]
struct Ctx {
/// Dimension → anonymous `*D` block name.
dim_blocks: HashMap<Handle, String>,
/// Upper-case text style name → STYLE record handle.
styles: HashMap<String, String>,
/// Upper-case arrow name (as stored in styles/overrides) → BLOCK_RECORD handle.
arrows: HashMap<String, String>,
/// Associative dimension → its DIMASSOC object.
assoc: HashMap<Handle, AssocObj>,
/// Table → (anonymous `*T` block name, BLOCK_RECORD handle).
tables: HashMap<Handle, (String, String)>,
/// Table style name → TABLESTYLE handle (the first is the fallback).
table_styles: Vec<(String, String)>,
}
impl Ctx {
fn style(&self, name: &str) -> Option<String> {
self.styles.get(&name.to_ascii_uppercase()).cloned()
}
fn arrow(&self, name: &str) -> Option<String> {
self.arrows.get(&name.trim().to_ascii_uppercase()).cloned()
}
fn table_style(&self, name: &str) -> String {
self.table_styles
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.or_else(|| self.table_styles.first())
.map(|(_, h)| h.clone())
.unwrap_or_else(|| "0".into())
}
/// A dimension variable encoded with this file's handles; integers outside the 16-bit
/// range of their groups are dropped.
fn encode(&self, kind: K, v: &serde_json::Value) -> Option<DimVal> {
dxf_ext::encode(kind, v, &|n| self.style(n), &|n| self.arrow(n)).filter(|x| !matches!(x, DimVal::Int(i) if i16::try_from(*i).is_err()))
}
}
/// `AcadAnnotative` xdata marking an annotative style.
fn annotative_xdata(w: &mut W) {
w.s(1001, "AcadAnnotative");
w.s(1000, "AnnotativeData");
w.s(1002, "{");
w.i(1070, 1);
w.i(1070, 1);
w.s(1002, "}");
}
/// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension.
fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {
let mut ov = W { t: Vec::new(), next: 0 };
let mut sah = false;
for (k, v) in &dm.overrides {
let Some(field) = DimStyle::field_name(k) else { continue };
let Some((code, kind)) = dxf_ext::dim_code(field) else { continue };
let Some(val) = dxf_ext::canonical(field, v) else { continue };
let Some(enc) = cx.encode(kind, &val) else { continue };
sah |= matches!(code, 343 | 344);
ov.i(1070, i64::from(code));
ov.dimval(code, enc, true);
}
if sah {
ov.i(1070, i64::from(dxf_ext::DIMSAH));
ov.i(1070, 1);
}
if !ov.t.is_empty() {
w.s(1001, "ACAD");
w.s(1000, "DSTYLE");
w.s(1002, "{");
w.xdata(ov.t);
w.s(1002, "}");
}
if !dm.assoc.is_empty() {
w.s(1001, dxf_ext::APP);
w.xdata(dxf_ext::assoc_xdata(&dm.assoc));
}
}
/// Header variables we write, with their group codes (DXF Reference, HEADER section).
@@ -113,7 +245,16 @@ fn header_vars(w: &mut W, d: &Drawing) {
}
fn common(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str) {
common_x(w, e, owner, paper, subclass, None);
}
/// Common entity groups; `assoc` adds the reactor and extension dictionary of a DIMASSOC.
fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, assoc: Option<&AssocObj>) {
w.s(5, e.handle.hex());
if let Some(a) = assoc {
w.group("ACAD_REACTORS", 330, &[&a.handle]);
w.group("ACAD_XDICTIONARY", 360, &[&a.xdict]);
}
w.s(330, owner);
w.s(100, "AcDbEntity");
if paper {
@@ -200,7 +341,7 @@ fn text_tags(w: &mut W, t: &Text, attrib_tag: Option<(&str, bool)>, attdef: bool
}
}
fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, dim_blocks: &std::collections::HashMap<Handle, String>) {
fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) {
match &e.kind {
EntityKind::Line(l) => {
w.s(0, "LINE");
@@ -383,8 +524,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, dim_bloc
}
EntityKind::Dimension(dm) => {
w.s(0, "DIMENSION");
common(w, e, owner, paper, "AcDbDimension");
let bname = dim_blocks.get(&e.handle).cloned().or_else(|| dm.block.clone()).unwrap_or_default();
common_x(w, e, owner, paper, "AcDbDimension", cx.assoc.get(&e.handle));
let bname = cx.dim_blocks.get(&e.handle).cloned().or_else(|| dm.block.clone()).unwrap_or_default();
w.s(2, bname);
w.p(10, dm.defpt);
let style = d.dim_style(&dm.style).cloned().unwrap_or_default();
@@ -447,6 +588,68 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, dim_bloc
w.p(14, dm.p14);
}
}
dim_xdata(w, dm, cx);
}
EntityKind::Table(t) => {
// Tables without a generated block (none expected) are not written.
let Some((bname, brh)) = cx.tables.get(&e.handle) else { return };
w.s(0, "ACAD_TABLE");
common(w, e, owner, paper, "AcDbBlockReference");
w.s(2, bname);
w.p(10, t.insert);
w.s(100, "AcDbTable");
w.i(280, 0);
w.s(342, cx.table_style(&t.style));
w.s(343, brh);
w.p(11, Vec3::new(1.0, 0.0, 0.0));
w.i(90, 0);
let (rows, cols) = table_size(t);
w.i(91, rows as i64);
w.i(92, cols as i64);
for c in [93, 94, 95, 96] {
w.i(c, 0);
}
for h in t.row_heights.iter().take(rows) {
w.f(141, *h);
}
for c in t.col_widths.iter().take(cols) {
w.f(142, *c);
}
let cov = cadcraft_render::table_covered(t);
let span = |v: u32| i64::from(v.min(i16::MAX as u32));
for r in 0..rows {
for c in 0..cols {
let cell = t.cells.get(r).and_then(|x| x.get(c));
let covered = cov.get(r).and_then(|x| x.get(c)).copied().unwrap_or(false);
let merge = cell.and_then(|x| x.merged).filter(|&(a, b)| a >= 1 && b >= 1 && (a, b) != (1, 1));
w.i(171, 1);
w.i(172, 0);
w.i(173, i64::from(covered || merge.is_some()));
w.i(174, 0);
let (rs, cs) = if covered { (0, 0) } else { merge.map(|(a, b)| (span(a), span(b))).unwrap_or((1, 1)) };
w.i(175, cs);
w.i(176, rs);
w.i(91, 0);
w.i(178, 0);
w.f(145, 0.0);
// Long text: 250-character code-2 chunks, the rest in code 1.
let chars: Vec<char> = cell.map(|x| x.text.chars().collect()).unwrap_or_default();
let chunks: Vec<String> = chars.chunks(250).map(|c| c.iter().collect()).collect();
let n = chunks.len();
for (i, ch) in chunks.into_iter().enumerate() {
w.s(if i + 1 == n { 1 } else { 2 }, ch);
}
if n == 0 {
w.s(1, "");
}
}
}
w.s(1001, dxf_ext::APP);
w.s(1000, "TABLE");
w.i(1070, i64::from(t.title));
w.i(1070, i64::from(t.header));
w.f(1040, t.text_height);
w.s(1000, &t.style);
}
EntityKind::Leader(l) => {
w.s(0, "LEADER");
@@ -584,6 +787,130 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec<Entity> {
out
}
/// Rows and columns written for a table (bounded like the renderer).
fn table_size(t: &Table) -> (usize, usize) {
(t.row_heights.len().min(10_000), t.col_widths.len().min(10_000))
}
/// The anonymous `*T` block of a table: cell borders and texts, for readers that only draw
/// the block.
fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
let (rows, cols) = table_size(t);
let mut xs = vec![0.0];
for w in t.col_widths.iter().take(cols) {
xs.push(xs.last().copied().unwrap_or(0.0) + w);
}
let mut ys = vec![0.0];
for h in t.row_heights.iter().take(rows) {
ys.push(ys.last().copied().unwrap_or(0.0) + h);
}
let x_at = |c: usize| xs.get(c).copied().unwrap_or(0.0);
let y_at = |r: usize| ys.get(r).copied().unwrap_or(0.0);
let p = |x: f64, y: f64| Vec3::new(x, -y, 0.0);
let cov = cadcraft_render::table_covered(t);
let margin = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&t.style)).map(|s| s.margin).unwrap_or(0.06).max(0.0);
let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() };
let mut out = Vec::new();
let mut line = |a: Vec3, b: Vec3| out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a, b }) });
let mut texts = Vec::new();
for r in 0..rows {
for col in 0..cols {
if cov.get(r).and_then(|v| v.get(col)).copied().unwrap_or(false) {
continue;
}
let cell = t.cells.get(r).and_then(|row| row.get(col));
let (rs, cs) = cell.and_then(|x| x.merged).map(|(a, b)| (a.max(1) as usize, b.max(1) as usize)).unwrap_or((1, 1));
let (r2, c2) = ((r + rs).min(rows), (col + cs).min(cols));
let (x0, x1, y0, y1) = (x_at(col), x_at(c2), y_at(r), y_at(r2));
line(p(x0, y1), p(x1, y1));
line(p(x1, y1), p(x1, y0));
if r == 0 {
line(p(x0, y0), p(x1, y0));
}
if col == 0 {
line(p(x0, y0), p(x0, y1));
}
let Some(cell) = cell.filter(|x| !x.text.is_empty()) else { continue };
let title = t.title && r == 0;
let header = t.header && r == usize::from(t.title);
let (insert, attach) = if title || header { (p((x0 + x1) / 2.0, (y0 + y1) / 2.0), 5) } else { (p(x0 + margin, (y0 + y1) / 2.0), 4) };
texts.push(Entity {
handle: Handle(0),
common: c.clone(),
kind: EntityKind::MText(MText {
insert,
height: if title { t.text_height * 1.4 } else { t.text_height },
width: 0.0,
attach,
rotation: 0.0,
style: "Standard".into(),
contents: cell.text.clone(),
line_spacing: 1.0,
}),
});
}
}
out.extend(texts);
out
}
/// Our own geometry for an arrowhead block: unit size, tip at the origin, pointing along +X.
fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec<Entity> {
let g = cadcraft_render::arrowhead(kind, Vec2::ZERO, Vec2::X, 1.0);
let c = Common { layer: "0".into(), color: Color::ByBlock, ..Common::default() };
let mut out = Vec::new();
for l in &g.lines {
for seg in l.windows(2) {
if let [a, b] = seg {
out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) });
}
}
}
for t in g.tris.chunks(3) {
if let [a, b, cc] = t {
out.push(Entity {
handle: Handle(0),
common: c.clone(),
kind: EntityKind::Solid(Solid { corners: [a.to3(0.0), b.to3(0.0), cc.to3(0.0), cc.to3(0.0)] }),
});
}
}
out
}
/// Standard DIMASSOC point references of an associative dimension: the extension-line
/// origins of linear/aligned dimensions and the feature point of ordinate dimensions, linked
/// to objects that exist. CADCraft's exact links travel in xdata as well.
fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec<OsnapRef>) {
let slots: &[&str] = match dm.kind {
DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"],
DimKind::Ordinate { .. } => &["p13"],
_ => &[],
};
let mut flags = 0;
let mut refs = Vec::new();
for (slot, name) in slots.iter().enumerate() {
let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue };
let Some(ent) = d.entity(a.handle) else { continue };
let point = if *name == "p13" { dm.p13 } else { dm.p14 };
let (osnap, param, other) = match &a.snap {
AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. } => (1, 0.0, None),
AssocSnap::Mid => (2, 0.0, None),
AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None),
AssocSnap::OnCircle { angle } => (10, *angle, None),
AssocSnap::Intersection { other } => {
if d.entity(*other).is_none() {
continue;
}
(6, 0.0, Some(*other))
}
};
flags |= 1 << slot;
refs.push(OsnapRef { osnap, main: a.handle, param, point, other });
}
(flags, refs)
}
fn table_head(w: &mut W, name: &str, count: usize) -> String {
let h = w.h();
w.s(0, "TABLE");
@@ -622,8 +949,8 @@ pub fn write(d: &Drawing) -> String {
let mut user_blocks: Vec<(String, String, &Block)> = d.blocks.values().map(|b| (b.name.clone(), String::new(), b.as_ref())).collect();
user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0));
let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect();
let mut cx = Ctx::default();
// Dimension blocks.
let mut dim_blocks: std::collections::HashMap<Handle, String> = std::collections::HashMap::new();
let mut dim_defs: Vec<(String, String, Vec<Entity>)> = Vec::new(); // filled below
let mut n = 1;
let all_spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect();
@@ -638,14 +965,94 @@ pub fn write(d: &Drawing) -> String {
n += 1;
let ents = dim_block_entities(d, dm, &e.common.layer);
let brh = w.h();
dim_blocks.insert(e.handle, name.clone());
cx.dim_blocks.insert(e.handle, name.clone());
dim_defs.push((name, brh, ents));
}
}
}
// Every entity, including block contents (for arrows, tables and overrides).
let every: Vec<&Entity> = all_spaces
.iter()
.flat_map(|s| s.iter().map(|e| e.as_ref()))
.chain(d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.as_ref())))
.collect();
// Arrowhead blocks: user blocks by name, otherwise generated from our own geometry.
let mut arrow_names: Vec<String> = Vec::new();
for s in &d.dim_styles {
arrow_names.extend([s.arrow_block.clone(), s.arrow_block1.clone(), s.arrow_block2.clone()]);
}
for e in &every {
if let EntityKind::Dimension(dm) = &e.kind {
for (k, v) in &dm.overrides {
let Some(f) = DimStyle::field_name(k).filter(|f| f.starts_with("arrowBlock")) else { continue };
if let Some(s) = dxf_ext::canonical(f, v).as_ref().and_then(|v| v.as_str()) {
arrow_names.push(s.to_string());
}
}
}
}
let mut arrow_defs: Vec<(String, String, Vec<Entity>)> = Vec::new();
for name in arrow_names {
let key = name.trim().to_ascii_uppercase();
if key.is_empty() || cx.arrows.contains_key(&key) {
continue;
}
if let Some((_, h, _)) = user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())) {
cx.arrows.insert(key, h.clone());
continue;
}
let Some((bname, kind)) = dxf_ext::arrow_block_name(&name) else { continue };
let existing = arrow_defs.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone());
let existing = existing.or_else(|| user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone()));
let h = match existing {
Some(h) => h,
None => {
let h = w.h();
arrow_defs.push((bname, h.clone(), arrow_block_entities(kind)));
h
}
};
cx.arrows.insert(key, h);
}
// Table blocks (`*T`).
let mut table_defs: Vec<(String, String, Vec<Entity>)> = Vec::new();
let mut tn = 1;
for e in &every {
if let EntityKind::Table(t) = &e.kind {
if cx.tables.contains_key(&e.handle) {
continue;
}
let mut name = format!("*T{tn}");
while d.block(&name).is_some() {
tn += 1;
name = format!("*T{tn}");
}
tn += 1;
let brh = w.h();
cx.tables.insert(e.handle, (name.clone(), brh.clone()));
table_defs.push((name, brh, table_block_entities(d, t, &e.common.layer)));
}
}
// DIMASSOC objects of associative dimensions in model and paper space.
for st in &all_spaces {
for e in st.iter() {
if let EntityKind::Dimension(dm) = &e.kind {
let (flags, refs) = std_assoc_refs(d, dm);
if !refs.is_empty() {
cx.assoc.insert(e.handle, AssocObj { xdict: w.h(), handle: w.h(), flags, refs });
}
}
}
}
let default_table_style = [TableStyle::default()];
let table_styles: &[TableStyle] = if d.table_styles.is_empty() { &default_table_style } else { &d.table_styles };
cx.table_styles = table_styles.iter().map(|s| (s.name.clone(), w.h())).collect();
let constraint_chunks = dxf_ext::constraint_chunks(&d.constraints, &d.parametric);
let root_dict = w.h();
let group_dict = w.h();
let layout_dict = w.h();
let table_style_dict = w.h();
let constraints_xrec = w.h();
let model_layout = w.h();
let layout_handles: Vec<String> = ps_brs.iter().map(|_| w.h()).collect();
@@ -672,6 +1079,22 @@ pub fn write(d: &Drawing) -> String {
// ---------------- CLASSES ----------------
w.s(0, "SECTION");
w.s(2, "CLASSES");
let mut classes = vec![("TABLESTYLE", "AcDbTableStyle", 4095, false)];
if !cx.tables.is_empty() {
classes.push(("ACAD_TABLE", "AcDbTable", 1025, true));
}
if !cx.assoc.is_empty() {
classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false));
}
for (dxf_name, cpp, proxy, is_entity) in classes {
w.s(0, "CLASS");
w.s(1, dxf_name);
w.s(2, cpp);
w.s(3, "ObjectDBX Classes");
w.i(90, proxy);
w.i(280, 0);
w.i(281, i64::from(is_entity));
}
w.s(0, "ENDSEC");
// ---------------- TABLES ----------------
@@ -734,26 +1157,34 @@ pub fn write(d: &Drawing) -> String {
// STYLE
let th = table_head(&mut w, "STYLE", d.text_styles.len());
for s in &d.text_styles {
record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord");
let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord");
cx.styles.entry(s.name.to_ascii_uppercase()).or_insert(h);
w.s(2, &s.name);
w.i(70, 0);
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
w.i(70, if s.vertical { 4 } else { 0 });
w.f(40, s.height);
w.f(41, s.width_factor);
w.f(50, s.oblique.to_degrees());
w.i(71, 0);
w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 });
w.f(42, 0.2);
w.s(3, if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() });
w.s(4, &s.big_font);
if s.annotative {
annotative_xdata(&mut w);
}
}
w.s(0, "ENDTAB");
for name in ["VIEW", "UCS"] {
table_head(&mut w, name, 0);
w.s(0, "ENDTAB");
}
let th = table_head(&mut w, "APPID", 1);
record_head(&mut w, "APPID", &th, "AcDbRegAppTableRecord");
w.s(2, "ACAD");
w.i(70, 0);
let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative"];
let th = table_head(&mut w, "APPID", apps.len());
for app in apps {
record_head(&mut w, "APPID", &th, "AcDbRegAppTableRecord");
w.s(2, app);
w.i(70, 0);
}
w.s(0, "ENDTAB");
// DIMSTYLE
let th = {
@@ -772,28 +1203,22 @@ pub fn write(d: &Drawing) -> String {
record_head(&mut w, "DIMSTYLE", &th, "AcDbDimStyleTableRecord");
w.s(2, &s.name);
w.i(70, 0);
if !s.post.is_empty() {
w.s(3, &s.post);
let v = serde_json::to_value(s).unwrap_or_default();
for (field, code, kind) in dxf_ext::DIM_CODES {
if let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) {
w.dimval(*code, enc, false);
}
}
if !s.arrow_block1.trim().is_empty() || !s.arrow_block2.trim().is_empty() {
w.i(dxf_ext::DIMSAH, 1);
}
if s.annotative {
annotative_xdata(&mut w);
}
w.f(40, s.scale);
w.f(41, s.arrow_size);
w.f(42, s.ext_offset);
w.f(43, s.baseline_spacing);
w.f(44, s.ext_extend);
w.f(140, s.text_height);
w.f(141, s.center_mark);
w.f(142, s.tick_size);
w.f(144, s.linear_factor);
w.f(147, s.text_gap);
w.i(73, i64::from(s.text_inside_horizontal));
w.i(74, i64::from(s.text_outside_horizontal));
w.i(77, i64::from(s.text_above));
w.i(78, i64::from(s.zero_suppression));
w.i(271, i64::from(s.decimals));
}
w.s(0, "ENDTAB");
// BLOCK_RECORD
let th = table_head(&mut w, "BLOCK_RECORD", 1 + ps_brs.len() + user_blocks.len() + dim_defs.len());
let th = table_head(&mut w, "BLOCK_RECORD", 1 + ps_brs.len() + user_blocks.len() + dim_defs.len() + arrow_defs.len() + table_defs.len());
let br_rec = |w: &mut W, h: &str, name: &str, layout: Option<&str>| {
w.s(0, "BLOCK_RECORD");
w.s(5, h);
@@ -810,7 +1235,7 @@ pub fn write(d: &Drawing) -> String {
for (n, h, _) in &user_blocks {
br_rec(&mut w, h, n, None);
}
for (n, h, _) in &dim_defs {
for (n, h, _) in dim_defs.iter().chain(&arrow_defs).chain(&table_defs) {
br_rec(&mut w, h, n, None);
}
w.s(0, "ENDTAB");
@@ -836,7 +1261,7 @@ pub fn write(d: &Drawing) -> String {
w.s(3, name);
w.s(1, "");
for e in ents {
entity(w, d, e, brh, false, &std::collections::HashMap::new());
entity(w, d, e, brh, false, &cx);
}
let eh = w.h();
w.s(0, "ENDBLK");
@@ -860,8 +1285,8 @@ pub fn write(d: &Drawing) -> String {
for (n, h, b) in &user_blocks {
block(&mut w, n, h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false);
}
// Dimension geometry entities need handles.
for (_, _, ents) in &mut dim_defs {
// Generated geometry entities (dimension, arrowhead and table blocks) need handles.
for (_, _, ents) in dim_defs.iter_mut().chain(arrow_defs.iter_mut()).chain(table_defs.iter_mut()) {
for e in ents.iter_mut() {
e.handle = Handle(w.next);
w.next += 1;
@@ -870,17 +1295,23 @@ pub fn write(d: &Drawing) -> String {
for (n, h, ents) in &dim_defs {
block(&mut w, n, h, Vec3::ZERO, 1, &mut ents.iter(), false);
}
for (n, h, ents) in &arrow_defs {
block(&mut w, n, h, Vec3::ZERO, 0, &mut ents.iter(), false);
}
for (n, h, ents) in &table_defs {
block(&mut w, n, h, Vec3::ZERO, 1, &mut ents.iter(), false);
}
w.s(0, "ENDSEC");
// ---------------- ENTITIES ----------------
w.s(0, "SECTION");
w.s(2, "ENTITIES");
for e in d.model.iter() {
entity(&mut w, d, e, &ms_br, false, &dim_blocks);
entity(&mut w, d, e, &ms_br, false, &cx);
}
if let Some((_, h, l)) = ps_brs.first() {
for e in l.entities.iter() {
entity(&mut w, d, e, h, true, &dim_blocks);
entity(&mut w, d, e, h, true, &cx);
}
}
w.s(0, "ENDSEC");
@@ -897,11 +1328,83 @@ pub fn write(d: &Drawing) -> String {
w.s(350, group_dict.clone());
w.s(3, "ACAD_LAYOUT");
w.s(350, layout_dict.clone());
w.s(3, "ACAD_TABLESTYLE");
w.s(350, table_style_dict.clone());
if constraint_chunks.is_some() {
w.s(3, dxf_ext::CONSTRAINTS_KEY);
w.s(350, constraints_xrec.clone());
}
w.s(0, "DICTIONARY");
w.s(5, group_dict);
w.s(330, root_dict.clone());
w.s(100, "AcDbDictionary");
w.i(281, 1);
// Table styles.
w.s(0, "DICTIONARY");
w.s(5, table_style_dict.clone());
w.s(330, root_dict.clone());
w.s(100, "AcDbDictionary");
w.i(281, 1);
for (name, h) in &cx.table_styles {
w.s(3, name);
w.s(350, h);
}
for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) {
table_style_obj(&mut w, s, h, &table_style_dict);
}
// Parametric constraints and parameters (CADCraft data).
if let Some(chunks) = &constraint_chunks {
w.s(0, "XRECORD");
w.s(5, constraints_xrec.clone());
w.group("ACAD_REACTORS", 330, &[&root_dict]);
w.s(330, root_dict.clone());
w.s(100, "AcDbXrecord");
w.i(280, 1);
for c in chunks {
w.s(1, c.clone());
}
}
// Dimension associativity: extension dictionary + DIMASSOC per dimension.
let mut assoc: Vec<(&Handle, &AssocObj)> = cx.assoc.iter().collect();
assoc.sort_by_key(|(h, _)| **h);
for (dim, a) in assoc {
let dh = dim.hex();
w.s(0, "DICTIONARY");
w.s(5, a.xdict.clone());
w.group("ACAD_REACTORS", 330, &[&dh]);
w.s(330, dh.clone());
w.s(100, "AcDbDictionary");
w.i(280, 1);
w.i(281, 1);
w.s(3, "ACAD_DIMASSOC");
w.s(360, a.handle.clone());
w.s(0, "DIMASSOC");
w.s(5, a.handle.clone());
w.group("ACAD_REACTORS", 330, &[&a.xdict]);
w.s(330, a.xdict.clone());
w.s(100, "AcDbDimAssoc");
w.s(330, dh);
w.i(90, a.flags);
w.i(70, 0);
w.i(71, 0);
for r in &a.refs {
w.s(1, "AcDbOsnapPointRef");
w.i(72, r.osnap);
w.s(331, r.main.hex());
w.i(73, 0);
w.i(91, 0);
w.s(301, "");
w.f(40, r.param);
w.p(10, r.point);
if let Some(o) = r.other {
w.s(332, o.hex());
w.i(74, 0);
w.i(92, 0);
w.s(302, "");
}
w.i(75, 0);
}
}
w.s(0, "DICTIONARY");
w.s(5, layout_dict.clone());
w.s(330, root_dict);
@@ -979,6 +1482,41 @@ pub fn write(d: &Drawing) -> String {
cadcraft_dxf::write_ascii(&w.t)
}
/// A TABLESTYLE object (DXF Reference, OBJECTS: TABLESTYLE): margins, title/header
/// suppression and per row type (data, column header, title) text and border settings.
fn table_style_obj(w: &mut W, s: &TableStyle, h: &str, dict: &str) {
w.s(0, "TABLESTYLE");
w.s(5, h);
w.group("ACAD_REACTORS", 330, &[dict]);
w.s(330, dict);
w.s(100, "AcDbTableStyle");
w.s(3, "");
w.i(70, 0);
w.i(71, 0);
w.f(40, s.margin);
w.f(41, s.margin);
w.i(280, i64::from(!s.title));
w.i(281, i64::from(!s.header));
// Data (middle left), column header and title (middle centre).
for (height, align) in [(s.text_height, 4), (s.text_height, 5), (s.text_height * 1.4, 5)] {
w.s(7, "Standard");
w.f(140, height);
w.i(170, align);
w.i(62, 0);
w.i(63, 7);
w.i(283, 0);
for c in 274..=279 {
w.i(c, -2);
}
for c in 284..=289 {
w.i(c, 1);
}
for c in 64..=69 {
w.i(c, 0);
}
}
}
fn cadcraft_fonts_name() -> &'static str {
"CADCraft Stroke"
}
+1
View File
@@ -2,6 +2,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod dxf_ext;
mod dxf_read;
mod dxf_write;
pub mod pdf;
+477
View File
@@ -360,3 +360,480 @@ fn dxf_roundtrips_page_setup() {
assert_eq!(p.margins_mm, [5.0, 6.0, 7.0, 8.0]);
assert_eq!(p.paper, a3.name);
}
// ---------------------------------------------------------------------------------------------
// Round trips of styles, overrides, associativity, constraints and tables.
fn dim(kind: DimKind, p13: Vec3, p14: Vec3) -> Dimension {
Dimension {
kind,
defpt: Vec3::new(p13.x, p13.y - 2.0, 0.0),
text_mid: Vec3::ZERO,
p13,
p14,
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
}
}
fn first<T>(d: &Drawing, f: impl Fn(&EntityKind) -> Option<T>) -> T {
d.model.iter().find_map(|e| f(&e.kind)).expect("entity")
}
fn roundtrip(d: &Drawing) -> Drawing {
read_dxf(write_dxf(d).as_bytes()).unwrap()
}
fn full_dim_style() -> DimStyle {
DimStyle {
name: "Mech".into(),
scale: 2.5,
arrow_size: 0.25,
ext_offset: 0.1,
ext_extend: 0.2,
text_height: 0.3,
text_gap: 0.05,
decimals: 3,
angular_decimals: 2,
linear_factor: 0.5,
text_above: 1,
text_inside_horizontal: false,
text_outside_horizontal: false,
arrow_block: "_ArchTick".into(),
tick_size: 0.15,
dim_line_color: Color::Index(1),
ext_line_color: Color::ByLayer,
text_color: Color::Index(5),
text_style: "Romans".into(),
post: "<> mm".into(),
center_mark: -0.1,
zero_suppression: 12,
linear_unit: 4,
tolerance: true,
tol_plus: 0.02,
tol_minus: 0.01,
baseline_spacing: 0.5,
annotative: true,
arrow_block1: "_DOT".into(),
arrow_block2: "_Open".into(),
dim_line_extend: 0.125,
text_just: 2,
round: 0.25,
decimal_separator: ",".into(),
fraction_format: 1,
limits: true,
tol_decimals: 2,
tol_scale: 0.75,
alt: true,
alt_factor: 0.03937,
alt_decimals: 3,
alt_post: "[<>]".into(),
angular_unit: 1,
suppress_ext1: true,
suppress_ext2: true,
}
}
#[test]
fn dimstyle_roundtrips_every_field() {
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
d.dim_styles.push(full_dim_style());
let text = write_dxf(&d);
// Arrowheads are blocks referenced by handle (342/343/344) with DIMSAH set.
assert!(text.contains("_ArchTick") && text.contains("_DOT") && text.contains("_Open"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Mech").unwrap(), &full_dim_style());
assert_eq!(back.dim_style("Standard").unwrap(), &DimStyle::default());
// Generated arrowhead blocks are not imported as user blocks, so they never pile up.
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{:?}", back.blocks.keys().collect::<Vec<_>>());
let again = roundtrip(&back);
assert_eq!(again.dim_styles, back.dim_styles);
assert_eq!(write_dxf(&again).matches("AcDbBlockBegin").count(), write_dxf(&back).matches("AcDbBlockBegin").count());
}
#[test]
fn user_block_arrowheads_are_referenced_not_regenerated() {
let mut d = Drawing::new_imperial();
let mut b = Block::new("MyArrow");
b.entities.push(Entity::new(Handle(0x60), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.5 })));
d.blocks.insert("MyArrow".into(), std::sync::Arc::new(b));
d.dim_styles.push(DimStyle { name: "U".into(), arrow_block: "MyArrow".into(), ..DimStyle::default() });
let back = roundtrip(&d);
assert_eq!(back.dim_style("U").unwrap().arrow_block, "MyArrow");
assert!(back.block("MyArrow").is_some());
assert_eq!(back.blocks.len(), d.blocks.len());
}
#[test]
fn dimension_overrides_roundtrip_as_dstyle_xdata() {
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
let raw = serde_json::json!({
"arrowSize": 0.5,
"dimLineColor": 3,
"textStyle": "Romans",
"arrowBlock1": "_Dot",
"arrowBlock": "",
"decimalSeparator": ",",
"suppressExt1": true,
"post": "<> mm",
"decimals": 2,
"textJust": 1,
"altFactor": 25.4,
});
dm.overrides = raw.as_object().unwrap().clone();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("DSTYLE"));
let back = read_dxf(text.as_bytes()).unwrap();
let got = first(&back, |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
// Values come back canonical (colours as colour objects); the effective style is identical.
assert_eq!(got.overrides.len(), dm.overrides.len(), "{:?}", got.overrides);
let base = DimStyle::default();
assert_eq!(base.with_overrides(&got.overrides), base.with_overrides(&dm.overrides));
assert_eq!(got.overrides.get("dimLineColor"), Some(&serde_json::to_value(Color::Index(3)).unwrap()));
// Canonical values round-trip exactly.
let again = first(&roundtrip(&back), |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
assert_eq!(again.overrides, got.overrides);
}
#[test]
fn dimension_associativity_roundtrips() {
let mut d = Drawing::new_imperial();
let c = Common::default;
let l1 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(0.0, 5.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(10.0, 0.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) })).unwrap();
let ci = d.add(&Space::Model, c(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0 })).unwrap();
let pl = d
.add(
&Space::Model,
c(),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::new(30.0, 0.0)), PolyVertex::new(Vec2::new(32.0, 0.0)), PolyVertex::new(Vec2::new(32.0, 3.0))],
closed: false,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
let mut lin = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
lin.assoc = vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Start },
];
let mut ali = dim(DimKind::Aligned, Vec3::new(22.0, 0.0, 0.0), Vec3::new(32.0, 3.0, 0.0));
ali.assoc = vec![
DimAssoc { point: "p13".into(), handle: ci, snap: AssocSnap::OnCircle { angle: 0.0 } },
DimAssoc { point: "p14".into(), handle: pl, snap: AssocSnap::Vertex { index: 2 } },
DimAssoc { point: "defpt".into(), handle: l1, snap: AssocSnap::Intersection { other: l2 } },
];
let mut rad = dim(DimKind::Radius, Vec3::new(20.0, 0.0, 0.0), Vec3::ZERO);
rad.assoc = vec![DimAssoc { point: "defpt".into(), handle: ci, snap: AssocSnap::Center }];
let hl = d.add(&Space::Model, c(), EntityKind::Dimension(lin.clone())).unwrap();
let ha = d.add(&Space::Model, c(), EntityKind::Dimension(ali.clone())).unwrap();
let hr = d.add(&Space::Model, c(), EntityKind::Dimension(rad.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("DIMASSOC") && text.contains("ACAD_DIMASSOC") && text.contains("AcDbOsnapPointRef"));
let back = read_dxf(text.as_bytes()).unwrap();
let assoc_of = |d: &Drawing, h: Handle| match &d.entity(h).unwrap().kind {
EntityKind::Dimension(x) => x.assoc.clone(),
_ => panic!(),
};
assert_eq!(assoc_of(&back, hl), lin.assoc);
assert_eq!(assoc_of(&back, ha), ali.assoc);
assert_eq!(assoc_of(&back, hr), rad.assoc);
// Entities owning reactors still land in model space.
assert_eq!(back.model.len(), d.model.len());
assert!(back.layouts.iter().all(|l| l.entities.is_empty()));
// Without CADCraft's xdata (a file from another writer), the standard DIMASSOC objects
// still give the extension-line links.
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
let mut stripped = Vec::new();
let mut skipping = false;
for t in tags {
if t.code == 1001 {
skipping = t.str() == "CADCRAFT";
} else if t.code < 1000 {
skipping = false;
}
if !skipping {
stripped.push(t);
}
}
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
assert_eq!(assoc_of(&foreign, hl), lin.assoc);
let fa = assoc_of(&foreign, ha);
assert_eq!(fa.len(), 2);
assert_eq!(fa[1], ali.assoc[1]);
assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9));
assert!(assoc_of(&foreign, hr).is_empty(), "radial links are CADCraft-only");
}
fn parametric_sample(d: &mut Drawing) -> (Vec<Constraint>, Parametric) {
let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(4.0, 0.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(4.0, 0.0, 0.0), b: Vec3::new(4.0, 3.0, 0.0) })).unwrap();
let constraints = vec![
Constraint { id: 1, kind: ConstraintKind::Horizontal, refs: vec![GeomRef::whole(l1)], name: String::new(), expr: String::new() },
Constraint {
id: 2,
kind: ConstraintKind::Coincident,
refs: vec![GeomRef::new(l1, Sub::End), GeomRef::new(l2, Sub::Start)],
name: String::new(),
expr: String::new(),
},
Constraint {
id: 3,
kind: ConstraintKind::Distance(DistAxis::Vertical),
refs: vec![GeomRef::new(l2, Sub::Start), GeomRef::new(l2, Sub::End)],
name: "d1".into(),
expr: "width/2 + 1".into(),
},
Constraint {
id: 4,
kind: ConstraintKind::Angular,
refs: vec![GeomRef::whole(l1), GeomRef::whole(l2)],
name: "ang1".into(),
expr: "90".into(),
},
Constraint {
id: 5,
kind: ConstraintKind::Fix,
refs: vec![GeomRef::new(l2, Sub::Vertex(7)), GeomRef::new(l2, Sub::Segment(3))],
name: String::new(),
expr: String::new(),
},
];
// Long text (chunk boundaries inside strings, spaces at the edges), backslashes that
// would look like DXF `\U+` escapes, and non-ASCII text.
let long = format!(" {} \\U+0041 \\\\ \"quoted\" Ünïcødé ✓ {} ", "word ".repeat(80), "x".repeat(300));
let parametric = Parametric {
parameters: vec![
Parameter { name: "width".into(), expr: "12.5".into(), description: long },
Parameter { name: "h".into(), expr: "width*0.4".into(), description: String::new() },
],
settings: ParametricSettings {
infer: true,
distance_tolerance: 0.125,
angle_tolerance: 2.5,
auto_types: vec!["Parallel".into(), "Tangent".into()],
bars_visible: false,
bar_exceptions: vec![l1],
dims_visible: false,
dim_exceptions: vec![l2],
bar_transparency: 30,
},
};
d.constraints = constraints.clone();
d.parametric = parametric.clone();
(constraints, parametric)
}
#[test]
fn constraints_and_parameters_roundtrip_exactly() {
let mut d = Drawing::new_imperial();
let (constraints, parametric) = parametric_sample(&mut d);
let text = write_dxf(&d);
assert!(text.contains("CADCRAFT_CONSTRAINTS") && text.contains("XRECORD"));
assert!(!text.contains("\\U+0041"), "no DXF unicode escape may appear in the payload");
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.constraints, constraints);
assert_eq!(back.parametric, parametric);
let again = roundtrip(&back);
assert_eq!(again.constraints, constraints);
assert_eq!(again.parametric, parametric);
// Drawings without parametric data carry no record.
assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_CONSTRAINTS"));
}
fn table_sample() -> Table {
let cell = |t: &str| TableCell { text: t.into(), merged: None };
let mut rows = vec![
vec![cell("Door schedule"), cell(""), cell("")],
vec![cell("Mark"), cell("Size"), cell("Notes")],
vec![cell("D1"), cell("900 x 2100"), cell(&"long note ".repeat(40))],
vec![cell("D2"), cell("800 x 2100"), cell("")],
];
rows[0][0].merged = Some((1, 3));
rows[2][0].merged = Some((2, 1));
Table {
insert: Vec3::new(5.0, 20.0, 0.0),
col_widths: vec![1.5, 2.5, 4.0],
row_heights: vec![0.5, 0.4, 0.4, 0.4],
cells: rows,
style: "Schedule".into(),
text_height: 0.2,
title: true,
header: true,
}
}
#[test]
fn tables_roundtrip_with_title_header_and_merges() {
let mut d = Drawing::new_imperial();
d.table_styles.push(TableStyle { name: "Schedule".into(), text_height: 0.25, margin: 0.1, title: false, header: true });
let t = table_sample();
d.add(&Space::Model, Common::default(), EntityKind::Table(t.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("ACAD_TABLE") && text.contains("TABLESTYLE") && text.contains("*T1"));
let back = read_dxf(text.as_bytes()).unwrap();
let got = first(&back, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None });
assert_eq!(got, t);
assert_eq!(back.table_styles, d.table_styles);
assert!(back.block("*T1").is_none(), "table blocks are regenerated, not imported");
let again = roundtrip(&back);
assert_eq!(first(&again, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None }), t);
assert_eq!(again.blocks.len(), back.blocks.len());
}
#[test]
fn text_style_flags_roundtrip() {
let mut d = Drawing::new_imperial();
let st = TextStyle {
name: "Mirror".into(),
font: "romans.shx".into(),
big_font: "bigfont.shx".into(),
height: 0.0,
width_factor: 0.8,
oblique: 15f64.to_radians(),
backwards: true,
upside_down: true,
vertical: true,
annotative: true,
};
d.text_styles.push(st.clone());
d.text_styles.push(TextStyle { name: "Plain".into(), font: "arial.ttf".into(), backwards: true, ..TextStyle::default() });
let back = roundtrip(&d);
let got = back.text_style("Mirror").unwrap();
assert_eq!((got.backwards, got.upside_down, got.vertical, got.annotative), (true, true, true, true));
assert_eq!(got.big_font, "bigfont.shx");
assert!((got.oblique - st.oblique).abs() < 1e-12);
let plain = back.text_style("Plain").unwrap();
assert_eq!((plain.backwards, plain.upside_down, plain.vertical, plain.annotative), (true, false, false, false));
}
#[test]
fn hostile_extension_data_never_panics() {
let ent = |body: &str| format!("0\nSECTION\n2\nENTITIES\n{body}0\nENDSEC\n0\nEOF\n");
let obj = |body: &str| format!("0\nSECTION\n2\nOBJECTS\n{body}0\nENDSEC\n0\nEOF\n");
let cases = [
// DSTYLE: unbalanced, odd counts, unknown codes, huge values, bad handles.
ent("0\nDIMENSION\n5\nA0\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n{\n1070\n"),
ent("0\nDIMENSION\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n{\n1070\n40\n1000\nnot a number\n1070\n271\n1070\n32767\n1070\n342\n1005\nZZZZ\n1070\n278\n1070\n-5\n1070\n176\n1070\n-32000\n1070\n9999\n1040\n1e308\n1002\n}\n"),
ent("0\nDIMENSION\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n}\n1002\n{\n1002\n{\n"),
// ASSOC: malformed links, missing handles, huge vertex index, nested braces.
ent("0\nDIMENSION\n70\n1\n1001\nCADCRAFT\n1000\nASSOC\n1002\n{\n1002\n{\n1000\np13\n1070\n6\n1071\n-1\n1002\n}\n1002\n{\n1000\nbogus\n1005\n1\n1070\n0\n1002\n}\n1002\n{\n1000\np14\n1005\nFFFFFFFFFFFFFFFFFFFF\n1070\n4\n1002\n}\n1002\n{\n1000\np14\n1005\n2A\n1070\n6\n1071\n2147483647\n1002\n}\n"),
ent("0\nDIMENSION\n70\n0\n1001\nCADCRAFT\n1000\nASSOC\n1002\n{\n1002\n{\n1002\n{\n1002\n{\n"),
// Tables: enormous or negative counts, cells without bodies, spans past the edge.
ent("0\nACAD_TABLE\n2\n*T1\n100\nAcDbTable\n91\n999999999\n92\n999999999\n171\n1\n0\nACAD_TABLE\n100\nAcDbTable\n91\n-4\n92\n3\n171\n"),
ent("0\nACAD_TABLE\n100\nAcDbTable\n91\n2\n92\n2\n141\nnan\n142\n-1\n171\n1\n173\n1\n175\n99999\n176\n-3\n1\nx\n171\n171\n171\n171\n171\n2\nzzz\n1001\nCADCRAFT\n1000\nTABLE\n1040\n-1\n"),
// DIMASSOC pointing at nothing, or at a non-dimension.
format!(
"{}{}",
"0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n10\n0\n20\n0\n11\n1\n21\n0\n0\nDIMENSION\n5\n11\n70\n0\n0\nENDSEC\n",
"0\nSECTION\n2\nOBJECTS\n0\nDIMASSOC\n100\nAcDbDimAssoc\n330\n11\n90\n-1\n1\nAcDbOsnapPointRef\n72\n1\n331\n10\n1\nAcDbOsnapPointRef\n72\n6\n331\n10\n332\n99\n1\n1\n1\n0\nDIMASSOC\n100\nAcDbDimAssoc\n330\n10\n90\n3\n0\nDIMASSOC\n0\nENDSEC\n0\nEOF\n"
),
// Constraint record: garbage, wrong version, deep nesting, unterminated.
obj("0\nDICTIONARY\n5\nC\n3\nCADCRAFT_CONSTRAINTS\n350\nD\n0\nXRECORD\n5\nD\n1\n{\"version\":1,\"constraints\":[{\"id\":1,\"kind\":\"nope\"}]}\n"),
obj(&format!("0\nDICTIONARY\n3\nCADCRAFT_CONSTRAINTS\n350\nD\n0\nXRECORD\n5\nD\n1\n{}\n", "[".repeat(5000))),
obj("0\nDICTIONARY\n3\nCADCRAFT_CONSTRAINTS\n350\nD\n3\nX\n0\nXRECORD\n5\nD\n1\n{\"version\":99}\n"),
obj("0\nDICTIONARY\n3\nCADCRAFT_CONSTRAINTS\n0\nXRECORD\n1\n{\"version\":1,\n"),
// Table styles with junk numbers; DIMSTYLE with junk and dangling handles.
obj("0\nDICTIONARY\n3\nS\n350\nE\n0\nTABLESTYLE\n5\nE\n40\n-7\n140\n0\n280\n9\n"),
"0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nDIMSTYLE\n0\nDIMSTYLE\n2\nX\n271\n99999\n340\nBAD\n342\n0\n343\nFFFF\n176\n70000\n278\n0\n278\n1114112\n77\n-1\n1001\nAcadAnnotative\n1000\nAnnotativeData\n1002\n{\n0\nSTYLE\n2\nS\n70\n-1\n71\n99999\n0\nENDTAB\n0\nENDSEC\n0\nEOF\n".to_string(),
];
for t in &cases {
if let Ok(d) = read(t.as_bytes(), "x.dxf") {
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let _ = write_dxf(&d);
}
}
// Huge but well-formed override lists are capped, not trusted.
let mut big = String::from("0\nSECTION\n2\nENTITIES\n0\nDIMENSION\n70\n0\n1001\nACAD\n1000\nDSTYLE\n1002\n{\n");
for _ in 0..50_000 {
big.push_str("1070\n41\n1040\n0.5\n");
}
big.push_str("1002\n}\n0\nENDSEC\n0\nEOF\n");
let d = read(big.as_bytes(), "x.dxf").unwrap();
let dm = first(&d, |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
assert_eq!(dm.overrides.len(), 1);
}
/// Everything above in one drawing (also used for external validation).
fn extension_sample() -> Drawing {
let mut d = sample();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), backwards: true, annotative: true, ..TextStyle::default() });
d.dim_styles.push(full_dim_style());
d.table_styles.push(TableStyle { name: "Schedule".into(), text_height: 0.25, margin: 0.1, title: true, header: true });
d.add(&Space::Model, Common::default(), EntityKind::Table(table_sample())).unwrap();
let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 30.0, 0.0), b: Vec3::new(0.0, 35.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(8.0, 30.0, 0.0), b: Vec3::new(8.0, 35.0, 0.0) })).unwrap();
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::new(0.0, 30.0, 0.0), Vec3::new(8.0, 30.0, 0.0));
dm.style = "Mech".into();
dm.assoc = vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Start },
];
dm.overrides = serde_json::json!({"arrowBlock2": "_BoxFilled", "textColor": 2, "decimals": 1}).as_object().unwrap().clone();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
let ci = d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 30.0, 0.0), radius: 2.0 })).unwrap();
let mut al = dim(DimKind::Aligned, Vec3::new(22.0, 30.0, 0.0), Vec3::new(8.0, 35.0, 0.0));
al.assoc = vec![
DimAssoc { point: "p13".into(), handle: ci, snap: AssocSnap::OnCircle { angle: 0.0 } },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Intersection { other: l1 } },
];
d.add(&Space::Model, Common::default(), EntityKind::Dimension(al)).unwrap();
parametric_sample(&mut d);
d
}
#[test]
fn extension_sample_roundtrips_and_can_be_exported() {
let d = extension_sample();
let text = write_dxf(&d);
if let Ok(path) = std::env::var("CADCRAFT_DXF_OUT") {
std::fs::write(path, &text).unwrap();
}
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.model.len(), d.model.len());
assert_eq!(back.constraints, d.constraints);
assert_eq!(back.dim_style("Mech"), d.dim_style("Mech"));
let kinds = |d: &Drawing| d.model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
assert_eq!(kinds(&back), kinds(&d));
}
/// DXF → DWG (acadrust) → DXF keeps what the DWG bridge supports: dimension styles with
/// arrow blocks, override and associativity xdata, the constraint XRECORD, tables and text
/// style flags. (TABLESTYLE objects do not survive the bridge.)
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_roundtrip_keeps_extension_data() {
let d = extension_sample();
let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap();
assert_eq!(back.dim_style("Mech"), d.dim_style("Mech"));
assert_eq!(back.constraints, d.constraints);
assert_eq!(back.parametric, d.parametric);
let dims =
|d: &Drawing| d.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect::<Vec<_>>();
let (a, b) = (dims(&d), dims(&back));
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.assoc, y.assoc);
let st = DimStyle::default();
assert_eq!(st.with_overrides(&x.overrides), st.with_overrides(&y.overrides));
}
assert_eq!(first(&back, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None }), table_sample());
let r = back.text_style("Romans").unwrap();
assert!(r.backwards && r.annotative);
}