Files
cadcraft/crates/io/src/dxf_ext.rs
T
aldozarandClaude Opus 5.5 f0e4fef003 AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours (#1062)
* AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours

What AutoCAD takes from CADCraft's DWG/DXF as it is:

- Justified TEXT/ATTRIB/ATTDEF (#1050): group 10 is now where the
  baseline starts, from the alignment point and the text's width, not
  the alignment point itself; AutoCAD drew centred and right-aligned
  text from DWG starting at its alignment point. A stored point close to
  CADCraft's own (AutoCAD's, exact for its fonts) is kept.
- DIMPOST (#1053) is written even when empty: the DWG bridge defaulted a
  missing one to "<>", and AutoCAD then dropped the "R" of radius
  dimensions when it regenerated them.
- The current dimension style (#1051): the DXF header now carries its
  DIM* variables (AutoCAD draws new and updated dimensions from them);
  the DWG bridge points $DIMSTYLE at the style by name (its handle was
  renumbered, so the writer fell back to Standard), and reading a DWG
  gives the current dimension and text styles their names back.
- *D blocks (#1052): lines, arrows, extension lines and text take the
  style's DIMCLRD/DIMCLRE/DIMCLRT instead of ByBlock.

#1054 (group 42 of a rotated linear dimension) was already fixed on
main by #855; acad_interop.rs keeps it covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: angular dimensions store their angle in radians (group 42)

Three-point (60°, 174°) and two-line (120°) angular dimensions, read back
from the DWG as DXF. Already right on main since #855; this guards it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Justified text made here: Left Middle and Left Bottom write their baseline start too

text_insert kept a stored insertion point that was within 0.1 x an
estimated width + 0.5 x height of CADCraft's baseline start, taking it for
AutoCAD's own. The width was estimated from the alignment point's offset,
which for Left Middle is only h/2 (vertical), so a text made here, whose
stored point is the alignment point, passed and kept it: AutoCAD drew it
half a height high (Left Bottom: a third). Reported with a test by
@lee-cyrille-maskell.

A stored point equal to the alignment point is now always stale. Otherwise
the offset is split along and across the baseline: along it the same slack
as before (a tenth of the text's width plus half its height, as fonts and
their substitutes differ most in width), across it a fifth of the height.
Multiline attribute values keep the distance-only check, since their lines
are placed by the embedded MTEXT.

On 12 drawings made in AutoCAD (1,055 justified texts) the insertion points
written are the same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:28:47 -04:00

891 lines
36 KiB
Rust

//! 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, hatch gradients, parametric
//! constraints).
//!
//! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS`, `CADCRAFT_LAYERSTATES`,
//! `CADCRAFT_PLOTSTYLES`, `CADCRAFT_HANDSEED` and `CADCRAFT_MLEADERSTYLE` (XRECORDs). Other
//! readers keep or ignore them.
use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
use cadcraft_dxf::{Tag, Version};
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";
/// Named-object-dictionary key of the saved layer states XRECORD.
pub(crate) const LAYER_STATES_KEY: &str = "CADCRAFT_LAYERSTATES";
/// Named-object-dictionary key of the drawing's plot style tables XRECORD.
pub(crate) const PLOT_STYLES_KEY: &str = "CADCRAFT_PLOTSTYLES";
/// Named-object-dictionary key of the annotative scaling XRECORD.
pub(crate) const ANNOTATION_KEY: &str = "CADCRAFT_ANNOTATION";
/// Named-object-dictionary key of the plot style names dictionary (DXF Reference): its entries
/// name `ACDBPLACEHOLDER` objects, and layers and objects point at them with group 390.
pub(crate) const PLOT_STYLE_NAMES_KEY: &str = "ACAD_PLOTSTYLENAME";
/// Named-object-dictionary key of the XRECORD holding the drawing's next entity handle (hex).
pub(crate) const HANDSEED_KEY: &str = "CADCRAFT_HANDSEED";
/// Named-object-dictionary key of the XRECORD holding the current multileader style.
pub(crate) const MLEADER_STYLE_KEY: &str = "CADCRAFT_MLEADERSTYLE";
/// Most plot style tables and plot style names read from a file.
pub(crate) const MAX_PLOT_STYLE_TABLES: usize = 1000;
pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000;
/// 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;
pub(crate) const MAX_LAYER_STATES: usize = 100_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, DIMLDRBLK).
Block,
/// Handle of an LTYPE record (DIMLTYPE, DIMLTEX1, DIMLTEX2); "" (ByBlock) isn't written.
Linetype,
/// An angle: degrees in the style, radians in the file (DIMJOGANG).
Angle,
}
/// [`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),
("altRound", 148, 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),
("altUnit", 273, K::Int),
("angularUnit", 275, K::Int),
("fractionFormat", 276, K::Int),
("linearUnit", 277, K::Int),
("decimalSeparator", 278, K::Char),
("textJust", 280, K::Int),
("tolZeroSuppression", 284, K::Int),
("altZeroSuppression", 285, K::Int),
("textStyle", 340, K::TextStyle),
("arrowBlock", 342, K::Block),
("arrowBlock1", 343, K::Block),
("arrowBlock2", 344, K::Block),
("fixedExtLength", 49, K::Real),
("jogAngle", 50, K::Angle),
("angularZeroSuppression", 79, K::Int),
("arcSymbol", 90, K::Int),
("textVerticalPos", 145, K::Real),
("forceLineInside", 172, K::Bool),
("textInside", 174, K::Bool),
("suppressOutsideArrows", 175, K::Bool),
("textMove", 279, K::Int),
("suppressDim1", 281, K::Bool),
("suppressDim2", 282, K::Bool),
("tolJust", 283, K::Int),
("fit", 289, K::Int),
("fixedExt", 290, K::Bool),
("leaderArrowBlock", 341, K::Block),
("dimLinetype", 345, K::Linetype),
("extLinetype1", 346, K::Linetype),
("extLinetype2", 347, K::Linetype),
("dimLineweight", 371, K::Int),
("extLineweight", 372, K::Int),
];
/// DIMSAH: separate arrow blocks in use.
pub(crate) const DIMSAH: i32 = 173;
/// DIMPOST's group in DIMSTYLE records.
pub(crate) const DIMPOST: i32 = 3;
/// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1,
/// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them
/// in a style's `CADCRAFT` "DIMVARS" xdata (`1070 <code>` then the value, like DSTYLE) instead of
/// groups an R2000 reader doesn't expect. The reader takes both.
pub(crate) const NEWER_CODES: &[i32] = &[49, 50, 90, 290, 345, 346, 347];
/// The first version with [`NEWER_CODES`] as DIMSTYLE groups: all came with AutoCAD 2006, whose
/// files are the 2004 format (AC1018).
pub(crate) const NEWER_SINCE: Version = Version::R2004;
/// Xdata list name of [`NEWER_CODES`] values.
pub(crate) const DIMVARS_LIST: &str = "DIMVARS";
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),
}
/// Name → handle lookups of the writer: text styles, arrowhead blocks and linetypes.
pub(crate) type Lookup<'a> = &'a dyn Fn(&str) -> Option<String>;
/// Encode a canonical field value. `style` / `block` / `ltype` map names to handles.
pub(crate) fn encode(kind: K, v: &Value, style: Lookup, block: Lookup, ltype: Lookup) -> 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)? })
}
K::Linetype => DimVal::Handle(ltype(v.as_str().filter(|n| !n.trim().is_empty())?)?),
K::Angle => DimVal::Real(v.as_f64().filter(|x| x.is_finite())?.to_radians()),
})
}
/// Decode a group value into the JSON a [`DimStyle`] field takes. `styles` / `blocks` /
/// `ltypes` map upper-case handles to names.
pub(crate) fn decode(
kind: K,
t: &Tag,
styles: &HashMap<String, String>,
blocks: &HashMap<String, String>,
ltypes: &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()) }
}
K::Linetype => {
let h = t.str().trim().to_ascii_uppercase();
if h.is_empty() || h == "0" { Value::String(String::new()) } else { Value::String(ltypes.get(&h)?.clone()) }
}
K::Angle => {
// Rounded so that whole degrees come back whole.
let x = (t.f64().to_degrees() * 1e9).round() / 1e9;
if !x.is_finite() {
return None;
}
serde_json::json!(x)
}
})
}
/// 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))
}
/// Applications whose xdata carries layer properties: transparency and description.
pub(crate) const LAYER_TRANSPARENCY_APP: &str = "AcCmTransparency";
pub(crate) const LAYER_DESCRIPTION_APP: &str = "AcAecLayerStandard";
/// A transparency percentage (0..=90) as an `AcCmTransparency` value: the alpha with the
/// "by alpha" flag (0x02 in the top byte).
pub(crate) fn transparency_to_dxf(percent: u8) -> i64 {
let t = u32::from(percent.min(90));
i64::from(0x0200_0000 | ((100 - t) * 255 / 100))
}
/// The percentage of an `AcCmTransparency` value; `None` unless it is "by alpha".
pub(crate) fn transparency_from_dxf(v: i64) -> Option<u8> {
if (v >> 24) & 0xff != 2 {
return None;
}
let alpha = u32::try_from(v & 0xff).ok()?;
let opaque = (alpha * 100 + 127) / 255;
u8::try_from(100u32.saturating_sub(opaque).min(90)).ok()
}
/// An xdata string cut to the 255 bytes a `1000` group may hold, at a character boundary.
pub(crate) fn xdata_str(s: &str) -> &str {
let mut end = s.len().min(255);
while !s.is_char_boundary(end) {
end -= 1;
}
s.get(..end).unwrap_or_default()
}
/// 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>,
ltypes: &HashMap<String, String>,
) -> serde_json::Map<String, Value> {
read_dimvar_list(xdata_list(xdata(tags, "ACAD"), "DSTYLE"), styles, blocks, ltypes)
}
/// A style's [`NEWER_CODES`] variables from its `CADCRAFT` "DIMVARS" xdata.
pub(crate) fn read_newer_dimvars(
tags: &[Tag],
styles: &HashMap<String, String>,
blocks: &HashMap<String, String>,
ltypes: &HashMap<String, String>,
) -> serde_json::Map<String, Value> {
read_dimvar_list(xdata_list(xdata(tags, APP), DIMVARS_LIST), styles, blocks, ltypes)
}
/// `1070 <code>` / value pairs of dimension variables.
fn read_dimvar_list(
list: &[Tag],
styles: &HashMap<String, String>,
blocks: &HashMap<String, String>,
ltypes: &HashMap<String, String>,
) -> serde_json::Map<String, Value> {
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, ltypes).and_then(|j| canonical(field, &j)) {
out.insert(field.to_string(), val);
}
}
out
}
/// Xdata marker of the paper-space viewport (id 1) the writer adds to layouts that have none;
/// CADCraft keeps that viewport implicit, so the reader drops it.
pub(crate) const PAPER_VIEW: &str = "PAPERVIEW";
/// True for a VIEWPORT record carrying the [`PAPER_VIEW`] marker as the first string of its CADCraft
/// xdata, where the writer puts it. Later strings are layer names (VPFROZEN, VPCOLORS), so a layer
/// called PAPERVIEW does not count.
pub(crate) fn is_paper_view(tags: &[Tag]) -> bool {
xdata(tags, APP).iter().find(|t| t.code == 1000).is_some_and(|t| t.str().trim().eq_ignore_ascii_case(PAPER_VIEW))
}
/// 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
}
/// An arc-length dimension as CADCraft xdata: `1000 ARCLEN`, `1002 {`, the arc centre as an xdata
/// point (`1010`/`1020`/`1030`), `1002 }`. The record itself stays a standard aligned dimension
/// (DXF has no arc-length DIMENSION type), so other programs still read it.
pub(crate) fn arclen_xdata(center: cadcraft_geom::Vec3) -> Vec<Tag> {
vec![Tag::s(1000, "ARCLEN"), Tag::s(1002, "{"), Tag::f(1010, center.x), Tag::f(1020, center.y), Tag::f(1030, center.z), Tag::s(1002, "}")]
}
/// Dimension marks as CADCraft xdata (`1000 MARKS`, `1002 {`, items, `1002 }`): `1000 JOGLINE`
/// and a point; `1000 JOGGED`, the centre and the jog point; `1000 BREAK`, a point, `1040` size
/// and `1070` auto; `1000 INSPECT`, `1070` shape, `1000` label and `1000` rate. Points are
/// `1010/1020/1030`. The oblique angle is the standard group 52 instead.
pub(crate) fn marks_xdata(m: &cadcraft_doc::DimMarks) -> Vec<Tag> {
let pt = |v: &mut Vec<Tag>, p: cadcraft_geom::Vec3| v.extend([Tag::f(1010, p.x), Tag::f(1020, p.y), Tag::f(1030, p.z)]);
let mut v = Vec::new();
if let Some(j) = m.jog_line {
v.push(Tag::s(1000, "JOGLINE"));
pt(&mut v, j);
}
if let Some(j) = &m.jogged {
v.push(Tag::s(1000, "JOGGED"));
pt(&mut v, j.center);
pt(&mut v, j.jog);
}
for b in m.breaks.iter().take(MAX_XDATA_ITEMS / 8) {
v.push(Tag::s(1000, "BREAK"));
pt(&mut v, b.at);
v.extend([Tag::f(1040, b.size), Tag::i(1070, i64::from(b.auto))]);
}
if let Some(i) = &m.inspect {
v.extend([Tag::s(1000, "INSPECT"), Tag::i(1070, i64::from(i.shape)), Tag::s(1000, i.label.clone()), Tag::s(1000, i.rate.clone())]);
}
if v.is_empty() {
return v;
}
let mut out = vec![Tag::s(1000, "MARKS"), Tag::s(1002, "{")];
out.extend(v);
out.push(Tag::s(1002, "}"));
out
}
/// The marks written by [`marks_xdata`] (no oblique angle); empty for other writers. Damaged
/// items are skipped.
pub(crate) fn read_marks(tags: &[Tag]) -> cadcraft_doc::DimMarks {
let list = xdata_list(xdata(tags, APP), "MARKS");
let list = list.get(..list.len().min(MAX_XDATA_ITEMS)).unwrap_or(&[]);
let mut m = cadcraft_doc::DimMarks::default();
let num = |i: usize, code: i32| list.get(i).filter(|t| t.code == code).map(Tag::f64).filter(|x| x.is_finite());
let point = |i: usize| Some(cadcraft_geom::Vec3::new(num(i, 1010)?, num(i + 1, 1020)?, num(i + 2, 1030)?));
let text = |i: usize| list.get(i).filter(|t| t.code == 1000).map(Tag::str);
for (i, t) in list.iter().enumerate() {
if t.code != 1000 {
continue;
}
match t.str().as_str() {
"JOGLINE" => m.jog_line = point(i + 1).or(m.jog_line),
"JOGGED" => {
if let (Some(center), Some(jog)) = (point(i + 1), point(i + 4)) {
m.jogged = Some(cadcraft_doc::JoggedRadius { center, jog });
}
}
"BREAK" => {
let size = list.get(i + 4).filter(|t| t.code == 1040).map(Tag::f64).filter(|x| x.is_finite() && *x > 0.0);
let auto = list.get(i + 5).filter(|t| t.code == 1070).is_some_and(|t| t.i64() != 0);
if let (Some(at), Some(size)) = (point(i + 1), size) {
m.breaks.push(cadcraft_doc::DimBreak { at, size, auto });
}
}
"INSPECT" => {
let shape = list.get(i + 1).filter(|t| t.code == 1070).and_then(|t| u8::try_from(t.i64()).ok()).filter(|s| *s <= 2);
if let (Some(shape), Some(label), Some(rate)) = (shape, text(i + 2), text(i + 3)) {
m.inspect = Some(cadcraft_doc::Inspection { shape, label, rate });
}
}
_ => {}
}
}
m
}
/// The arc centre written by [`arclen_xdata`]; None for other dimensions and other writers.
pub(crate) fn read_arclen(tags: &[Tag]) -> Option<cadcraft_geom::Vec3> {
let list = xdata_list(xdata(tags, APP), "ARCLEN");
let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite());
Some(cadcraft_geom::Vec3::new(g(1010)?, g(1020)?, g(1030).unwrap_or(0.0)))
}
/// A viewport's frozen layer names as CADCraft xdata: `1000 VPFROZEN`, `1002 {`, one `1000` per layer, `1002 }`.
pub(crate) fn frozen_xdata(layers: &[String]) -> Vec<Tag> {
let mut v = vec![Tag::s(1000, "VPFROZEN"), Tag::s(1002, "{")];
v.extend(layers.iter().map(|l| Tag::s(1000, l.clone())));
v.push(Tag::s(1002, "}"));
v
}
/// The frozen layer names written by [`frozen_xdata`]; empty for files from other writers.
pub(crate) fn read_frozen(tags: &[Tag]) -> Vec<String> {
let list = xdata_list(xdata(tags, APP), "VPFROZEN");
list.iter().take(MAX_XDATA_ITEMS).filter(|t| t.code == 1000).map(Tag::str).collect()
}
/// A viewport's layer colour overrides as CADCraft xdata: `1000 VPCOLORS`, `1002 {`, then per override
/// `1000 layer` and `1000 colour` (the [`Color::name`] text, which keeps true colours), `1002 }`.
pub(crate) fn layer_colors_xdata(colors: &[(String, Color)]) -> Vec<Tag> {
let mut v = vec![Tag::s(1000, "VPCOLORS"), Tag::s(1002, "{")];
for (layer, color) in colors {
v.push(Tag::s(1000, layer.clone()));
v.push(Tag::s(1000, color.name()));
}
v.push(Tag::s(1002, "}"));
v
}
/// The overrides written by [`layer_colors_xdata`]; empty for files from other writers, bad pairs are skipped.
pub(crate) fn read_layer_colors(tags: &[Tag]) -> Vec<(String, Color)> {
let list = xdata_list(xdata(tags, APP), "VPCOLORS");
let names: Vec<String> = list.iter().take(MAX_XDATA_ITEMS * 2).filter(|t| t.code == 1000).map(Tag::str).collect();
names.as_chunks::<2>().0.iter().filter_map(|[name, color]| Some((name.clone(), Color::parse(color)?))).collect()
}
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.
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()?;
Some(json_chunks(&json))
}
/// JSON text split into XRECORD strings of at most 250 characters. Backslashes are written as
/// `\u005c` so no chunk contains a DXF `\U+` escape.
fn json_chunks(json: &str) -> Vec<String> {
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();
chars.chunks(250).map(|c| c.iter().collect()).collect()
}
/// An associative array's parameters as CADCraft xdata on its block record: `1000 ARRAY`,
/// `1002 {`, the JSON text in `1000` strings short enough for the 255-byte limit even when
/// characters are written as `\U+XXXX` escapes, `1002 }`.
pub(crate) fn array_xdata(p: &ArrayParams) -> Vec<Tag> {
let Ok(json) = serde_json::to_string(p) else { return Vec::new() };
let safe = json_chunks(&json).concat();
let mut v = vec![Tag::s(1000, "ARRAY"), Tag::s(1002, "{")];
let (mut cur, mut cost) = (String::new(), 0usize);
for c in safe.chars() {
let n = if c.is_ascii() { 1 } else { 7 * c.len_utf16() };
if cost + n > 240 {
v.push(Tag::s(1000, std::mem::take(&mut cur)));
cost = 0;
}
cur.push(c);
cost += n;
}
if !cur.is_empty() {
v.push(Tag::s(1000, cur));
}
v.push(Tag::s(1002, "}"));
v
}
/// The array parameters written by [`array_xdata`]; `None` for other records and for
/// parameters that are not usable.
pub(crate) fn read_array(tags: &[Tag]) -> Option<ArrayParams> {
let list = xdata_list(xdata(tags, APP), "ARRAY");
let mut text = String::new();
for t in list.iter().take(MAX_XDATA_ITEMS).filter(|t| t.code == 1000) {
text.push_str(&t.str());
}
let p: ArrayParams = serde_json::from_str(&text).ok()?;
p.is_valid().then_some(p)
}
/// The saved layer states stored in the `CADCRAFT_LAYERSTATES` XRECORD.
#[derive(Serialize, Deserialize)]
struct LayerStatesPayload {
version: u32,
#[serde(default)]
states: Vec<LayerState>,
}
/// JSON chunks for the layer states XRECORD, or `None` when the drawing has no saved layer
/// states.
pub(crate) fn layer_state_chunks(states: &[LayerState]) -> Option<Vec<String>> {
if states.is_empty() {
return None;
}
let json = serde_json::to_string(&LayerStatesPayload { version: 1, states: states.to_vec() }).ok()?;
Some(json_chunks(&json))
}
/// Parse the layer states XRECORD text; `None` when it is not a payload we understand.
pub(crate) fn parse_layer_states(text: &str) -> Option<Vec<LayerState>> {
if text.len() > MAX_PAYLOAD {
return None;
}
let p: LayerStatesPayload = serde_json::from_str(text).ok()?;
if p.version != 1 || p.states.len() > MAX_LAYER_STATES {
return None;
}
Some(p.states)
}
/// The drawing's plot style tables stored in the `CADCRAFT_PLOTSTYLES` XRECORD.
#[derive(Serialize, Deserialize)]
struct PlotStylesPayload {
version: u32,
#[serde(default)]
tables: Vec<PlotStyleTable>,
}
/// JSON chunks for the plot style tables XRECORD, or `None` when the drawing keeps none.
pub(crate) fn plot_style_chunks(tables: &[PlotStyleTable]) -> Option<Vec<String>> {
if tables.is_empty() {
return None;
}
let json = serde_json::to_string(&PlotStylesPayload { version: 1, tables: tables.to_vec() }).ok()?;
Some(json_chunks(&json))
}
/// Parse the plot style tables XRECORD text (tables sanitized); `None` when it is not a
/// payload we understand.
pub(crate) fn parse_plot_styles(text: &str) -> Option<Vec<PlotStyleTable>> {
if text.len() > MAX_PAYLOAD {
return None;
}
let p: PlotStylesPayload = serde_json::from_str(text).ok()?;
if p.version != 1 || p.tables.len() > MAX_PLOT_STYLE_TABLES {
return None;
}
Some(p.tables.into_iter().map(PlotStyleTable::sanitized).filter(|t| !t.name.is_empty()).collect())
}
/// Annotative scaling stored in the `CADCRAFT_ANNOTATION` XRECORD: the current annotation scale
/// (CANNOSCALE), ANNOALLVISIBLE, the annotative blocks and the scales each annotative object
/// supports, by handle. The DXF Reference doesn't document how objects refer to their scales,
/// so this is CADCraft's own record; the scale list itself is the standard `ACAD_SCALELIST`.
#[derive(Default, Serialize, Deserialize)]
pub(crate) struct AnnoPayload {
version: u32,
#[serde(default)]
cannoscale: Option<String>,
#[serde(default)]
annoallvisible: Option<i64>,
#[serde(default)]
blocks: Vec<String>,
#[serde(default)]
objects: Vec<AnnoObject>,
}
#[derive(Serialize, Deserialize)]
struct AnnoObject {
handle: String,
scales: Vec<cadcraft_doc::AnnoScale>,
}
/// Most annotative objects read from one file.
const MAX_ANNO_OBJECTS: usize = 1_000_000;
/// JSON chunks for the annotative scaling XRECORD, or `None` when nothing differs from a new
/// drawing (1:1, ANNOALLVISIBLE 1, no annotative objects or blocks).
pub(crate) fn anno_chunks(d: &cadcraft_doc::Drawing) -> Option<Vec<String>> {
let cannoscale = d.header.str("CANNOSCALE", "1:1");
let annoallvisible = d.header.i64("ANNOALLVISIBLE", 1);
let blocks: Vec<String> = d.blocks.values().filter(|b| b.annotative).map(|b| b.name.clone()).collect();
let objects: Vec<AnnoObject> = d
.model
.iter()
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
.filter(|e| cadcraft_doc::is_annotative(e))
.map(|e| AnnoObject { handle: e.handle.hex(), scales: e.common.anno_scales.clone() })
.collect();
if cannoscale == "1:1" && annoallvisible == 1 && blocks.is_empty() && objects.is_empty() {
return None;
}
let p = AnnoPayload { version: 1, cannoscale: Some(cannoscale), annoallvisible: Some(annoallvisible), blocks, objects };
Some(json_chunks(&serde_json::to_string(&p).ok()?))
}
/// Parse the annotative scaling XRECORD text; `None` when it is not a payload we understand.
pub(crate) fn parse_anno(text: &str) -> Option<AnnoPayload> {
if text.len() > MAX_PAYLOAD {
return None;
}
let p: AnnoPayload = serde_json::from_str(text).ok()?;
(p.version == 1 && p.objects.len() <= MAX_ANNO_OBJECTS && p.blocks.len() <= MAX_ANNO_OBJECTS).then_some(p)
}
/// Apply a parsed annotative scaling record: variables, annotative blocks and each object's
/// scales (invalid scales and objects that can't be annotative are skipped).
pub(crate) fn apply_anno(d: &mut cadcraft_doc::Drawing, p: AnnoPayload) {
if let Some(c) = p.cannoscale.filter(|c| !c.trim().is_empty() && c.len() <= 255) {
d.header.set_str("CANNOSCALE", &c);
}
if let Some(v) = p.annoallvisible {
d.header.set_i64("ANNOALLVISIBLE", i64::from(v != 0));
}
// Block names compare case-insensitively; one map keeps a long hostile list linear.
let by_upper: std::collections::HashMap<String, String> = d.blocks.keys().map(|k| (k.to_ascii_uppercase(), k.clone())).collect();
for name in &p.blocks {
let key = by_upper.get(&name.to_ascii_uppercase()).cloned();
if let Some(b) = key.and_then(|k| d.blocks.get_mut(&k)) {
std::sync::Arc::make_mut(b).annotative = true;
}
}
for o in p.objects {
let Some(h) = Handle::parse_hex(&o.handle) else { continue };
let mut scales: Vec<cadcraft_doc::AnnoScale> = Vec::new();
for s in o.scales.into_iter().filter(|s| s.is_valid() && !s.name.trim().is_empty()) {
if scales.len() < cadcraft_doc::MAX_SCALES && !scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) {
scales.push(s);
}
}
let _ = d.modify_entity(h, |e| {
if cadcraft_doc::can_be_annotative(&e.kind) {
e.common.anno_scales = scales;
}
});
}
}
/// A SCALE object (DXF Reference, OBJECTS section): name (300), paper units (140), drawing
/// units (141); `None` when unusable.
pub(crate) fn read_scale(tags: &[Tag]) -> Option<cadcraft_doc::AnnoScale> {
let name = tags.iter().find(|t| t.code == 300).map(Tag::str)?;
let num = |c: i32| tags.iter().find(|t| t.code == c).map(Tag::f64);
let s = cadcraft_doc::AnnoScale::new(name.trim(), num(140)?, num(141)?);
(s.is_valid() && !s.name.is_empty() && s.name.len() <= 255).then_some(s)
}
/// 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"));
}
}