From d2ce7e6fef7325ab6724494458a2b36bd03f447a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 02:05:03 -0400 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/io/Cargo.toml | 1 + crates/io/src/dxf_ext.rs | 425 ++++++++++++++++++++++++++ crates/io/src/dxf_read.rs | 368 +++++++++++++++++++--- crates/io/src/dxf_write.rs | 610 ++++++++++++++++++++++++++++++++++--- crates/io/src/lib.rs | 1 + crates/io/src/tests.rs | 477 +++++++++++++++++++++++++++++ 7 files changed, 1813 insertions(+), 70 deletions(-) create mode 100644 crates/io/src/dxf_ext.rs diff --git a/Cargo.lock b/Cargo.lock index 23a4ff0af..0ac14ae55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,7 @@ dependencies = [ "cadcraft-geom", "cadcraft-render", "miniz_oxide 0.8.9", + "serde", "serde_json", "thiserror 2.0.21", ] diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 2eb760011..12113caab 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -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" diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs new file mode 100644 index 000000000..88328de40 --- /dev/null +++ b/crates/io/src/dxf_ext.rs @@ -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 { + 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, block: &dyn Fn(&str) -> Option) -> Option { + 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::(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, blocks: &HashMap) -> Option { + 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::().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 ` `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 { + 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 ` followed by the value. +pub(crate) fn read_dstyle(tags: &[Tag], styles: &HashMap, blocks: &HashMap) -> serde_json::Map { + 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 { + 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 { + 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 = 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, + #[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> { + 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 = 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, 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")); + } +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index fe0f6966a..96a1371cb 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -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 = head.all_f(141).into_iter().take(rows).map(size).collect(); + row_heights.resize(rows, 0.27); + let mut col_widths: Vec = 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 = 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> = 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 = 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 { 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 { })) } +/// Handle maps and references gathered while reading. +#[derive(Default)] +struct Rx { + /// STYLE record handle (upper case) → text style name. + styles: HashMap, + /// BLOCK_RECORD handle (upper case) → block name. + brs: HashMap, + /// Upper-case names of anonymous `*T` blocks drawn by ACAD_TABLE entities. + table_blocks: std::collections::HashSet, + /// Upper-case block names used by INSERTs. + inserted: std::collections::HashSet, + /// 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)], d: &mut Drawing) -> Vec<(Option, bool, Entity)> { +fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> Vec<(Option, 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)], d: &mut Drawing) -> Vec<(Option 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)], d: &mut Drawing) -> Vec<(Option 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) { - 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 *x = st, @@ -633,33 +766,22 @@ fn tables(tags: &[Tag], d: &mut Drawing, block_records: &mut HashMap *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 { } let mut d = Drawing::new_imperial(); d.layouts.clear(); - let mut block_records: HashMap = HashMap::new(); + let mut rx = Rx::default(); let mut blocks: Vec<(String, Vec3, Vec<(Option, 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 { 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 { } } } - "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 { 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 { } 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 { // 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 { 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 { Ok(d) } +/// Non-graphical objects applied once every entity is placed. +#[derive(Default)] +struct Objects { + /// Dictionary entry handle (upper case) → entry name. + names: HashMap, + /// XRECORD handle (upper case) → its groups. + xrecords: HashMap>, + /// TABLESTYLE objects: (handle, groups). + table_styles: Vec<(String, Vec)>, + /// DIMASSOC objects. + dimassocs: Vec>, +} + +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 = 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 = 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, at: Vec2) -> Option { + 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(); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index da5274397..fa5149676 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -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, 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) { + 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, +} + +/// An `AcDbOsnapPointRef` of a DIMASSOC object. +struct OsnapRef { + osnap: i64, + main: Handle, + param: f64, + point: Vec3, + other: Option, +} + +/// 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, + /// Upper-case text style name → STYLE record handle. + styles: HashMap, + /// Upper-case arrow name (as stored in styles/overrides) → BLOCK_RECORD handle. + arrows: HashMap, + /// Associative dimension → its DIMASSOC object. + assoc: HashMap, + /// Table → (anonymous `*T` block name, BLOCK_RECORD handle). + tables: HashMap, + /// Table style name → TABLESTYLE handle (the first is the fallback). + table_styles: Vec<(String, String)>, +} + +impl Ctx { + fn style(&self, name: &str) -> Option { + self.styles.get(&name.to_ascii_uppercase()).cloned() + } + fn arrow(&self, name: &str) -> Option { + 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 { + 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) { +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 = cell.map(|x| x.text.chars().collect()).unwrap_or_default(); + let chunks: Vec = 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 { 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 { + 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 { + 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) { + 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 = std::collections::HashMap::new(); let mut dim_defs: Vec<(String, String, Vec)> = 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 = 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)> = 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)> = 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 = 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" } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 7a2387fc4..3b066f391 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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; diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index aacc617b3..8636c83a0 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -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(d: &Drawing, f: impl Fn(&EntityKind) -> Option) -> 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::>()); + 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, 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::>(); + 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::>(); + 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); +}