Files
cadcraft/crates/io/src/tests.rs
T
5b037b8f92 DXF: write viewport frozen layers as group 341 layer handles too (#827)
A layout VIEWPORT's frozen layers were saved only as CADCraft's own
1001 CADCRAFT / 1000 VPFROZEN xdata. Other programs ignore that, so in
any third-party viewer every viewport showed every layer.

The writer now also emits the standard group 341: one soft pointer per
frozen layer to its LAYER table record handle, after 90/340 and before
the xdata. A frozen name with no LAYER record is skipped for 341 only
(the xdata still carries it). The reader keeps preferring the xdata; a
VIEWPORT without it (a file from another program) resolves its 341
handles through the LAYER records' group-5 handles, ignoring unknown
handles and keeping the 341 order.

Not covered: the DXF-to-DWG converter does not carry group 341 yet, so
a DWG keeps per-viewport freezing only through the CADCraft xdata.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 04:46:15 -04:00

2139 lines
96 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
use crate::*;
fn sample() -> Drawing {
let mut d = Drawing::new_imperial();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
let c = |l: &str| Common { layer: l.into(), ..Common::default() };
d.add(&Space::Model, c("Walls"), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) })).unwrap();
d.add(&Space::Model, c("0"), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.5 })).unwrap();
d.add(&Space::Model, c("0"), EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.5, end: 2.0 })).unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::with_bulge(Vec2::new(4.0, 0.0), 0.5), PolyVertex::new(Vec2::new(4.0, 3.0))],
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Text(Text {
insert: Vec3::new(1.0, 1.0, 0.0),
align_pt: None,
height: 0.25,
value: "Hello Café".into(),
rotation: 0.3,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::MText(MText {
insert: Vec3::new(5.0, 5.0, 0.0),
height: 0.2,
width: 3.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: "line one\\Pline two".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Ellipse(Ellipse {
center: Vec3::new(8.0, 8.0, 0.0),
major: Vec3::new(3.0, 0.0, 0.0),
ratio: 0.5,
start: 0.0,
end: std::f64::consts::TAU,
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Spline(cadcraft_geom::Spline::from_fit_points(&[Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 1.0), Vec2::new(4.0, 3.0)])),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Dimension(Dimension {
kind: DimKind::Linear { rotation: 0.0 },
defpt: Vec3::new(0.0, -1.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 10.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}),
)
.unwrap();
d.add(
&Space::Model,
c("0"),
EntityKind::Hatch(Hatch {
pattern: "ANSI31".into(),
solid: false,
loops: vec![HatchLoop {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::new(Vec2::new(2.0, 0.0)), PolyVertex::new(Vec2::new(2.0, 2.0))],
outer: true,
sources: Vec::new(),
}],
scale: 1.0,
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: None,
origin: Vec2::ZERO,
background: None,
pattern_lines: Vec::new(),
}),
)
.unwrap();
let mut b = Block::new("Bolt");
b.entities.push(Entity::new(Handle(0x50), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.25 })));
d.blocks.insert("Bolt".into(), std::sync::Arc::new(b));
d.add(
&Space::Model,
c("0"),
EntityKind::Insert(Insert {
block: "Bolt".into(),
insert: Vec3::new(2.0, 2.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
)
.unwrap();
d.add(&Space::Paper("Layout1".into()), c("0"), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) })).unwrap();
d
}
#[test]
fn dxf_roundtrip_preserves_entities() {
let d = sample();
let text = write_dxf(&d);
let back = read_dxf(text.as_bytes()).unwrap();
let kinds = |d: &Drawing| d.model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
assert_eq!(kinds(&back), kinds(&d));
assert_eq!(back.layer("Walls").unwrap().color, Color::Index(1));
assert!(back.block("Bolt").is_some());
assert_eq!(back.layout("Layout1").unwrap().entities.len(), 1);
// Geometry survives.
for (a, b) in d.model.iter().zip(back.model.iter()) {
match (&a.kind, &b.kind) {
(EntityKind::Line(x), EntityKind::Line(y)) => assert_eq!(x, y),
(EntityKind::Circle(x), EntityKind::Circle(y)) => assert_eq!(x, y),
(EntityKind::LwPolyline(x), EntityKind::LwPolyline(y)) => assert_eq!(x.vertices, y.vertices),
(EntityKind::Text(x), EntityKind::Text(y)) => {
assert_eq!(x.value, y.value);
assert!((x.rotation - y.rotation).abs() < 1e-9);
}
(EntityKind::MText(x), EntityKind::MText(y)) => assert_eq!(x.contents, y.contents),
(EntityKind::Hatch(x), EntityKind::Hatch(y)) => assert_eq!(x.loops, y.loops),
_ => {}
}
assert_eq!(a.handle, b.handle);
assert_eq!(a.common.layer, b.common.layer);
}
// Handles stay unique after reading.
let mut back = back;
let h = back.new_handle();
assert!(back.entity(h).is_none());
}
#[test]
fn lwpolyline_elevation_roundtrips() {
for elevation in [5.0, -2.5, 0.0] {
let mut d = Drawing::new_metric();
d.add(
&Space::Model,
Common::default(),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::new(Vec2::new(10.0, 0.0)), PolyVertex::new(Vec2::new(10.0, 10.0))],
closed: true,
const_width: 0.0,
elevation,
plinegen: false,
}),
)
.unwrap();
let back = roundtrip(&d);
let again = roundtrip(&back);
for doc in [&back, &again] {
let p = first(doc, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert_eq!(p.elevation, elevation);
assert_eq!(p.vertices.len(), 3);
}
}
}
#[test]
fn second_roundtrip_is_stable() {
let d = sample();
let t1 = write_dxf(&d);
let d2 = read_dxf(t1.as_bytes()).unwrap();
let d3 = read_dxf(write_dxf(&d2).as_bytes()).unwrap();
assert_eq!(d2.model.len(), d3.model.len());
}
#[test]
fn layer_transparency_and_description_roundtrip() {
// Issues #96 and #97: both are LAYER xdata (AcCmTransparency, AcAecLayerStandard).
let layers = [("T0", 0, ""), ("T1", 1, "One"), ("T40", 40, "Existing brick walls to remain"), ("T90", 90, "Ångström – ünïcode")];
let mut d = Drawing::new_imperial();
for (name, transparency, description) in layers {
d.layers.push(Layer {
name: name.into(),
transparency,
description: description.into(),
color: Color::Index(1),
plot: false,
..Layer::default()
});
}
let long = "é".repeat(200);
d.layers.push(Layer { name: "Long".into(), description: long.clone(), ..Layer::default() });
let text = write_dxf(&d);
let back = read_dxf(text.as_bytes()).unwrap();
for (name, transparency, description) in layers {
let l = back.layer(name).unwrap();
assert_eq!((l.transparency, l.description.as_str(), l.color, l.plot), (transparency, description, Color::Index(1), false), "{name}");
}
// An xdata string holds at most 255 bytes; the cut falls on a character boundary.
assert_eq!(back.layer("Long").unwrap().description, long[..254]);
// AutoCAD's encoding: the alpha with the "by alpha" flag, under registered applications.
assert!(text.contains("AcCmTransparency\r\n1071\r\n33554585\r\n"), "40% is alpha 153");
assert_eq!(text.matches("AcDbRegAppTableRecord\r\n 2\r\nAcCmTransparency").count(), 1);
assert!(text.contains("AcDbRegAppTableRecord\r\n 2\r\nAcAecLayerStandard"));
// Values that aren't "by alpha" (ByLayer, ByBlock, junk) leave the layer opaque.
for v in [0, 0x0100_0000, -1, i64::MAX] {
assert_eq!(crate::dxf_ext::transparency_from_dxf(v), None, "{v:#x}");
}
assert_eq!(crate::dxf_ext::transparency_from_dxf(0x0200_00ff), Some(0));
assert_eq!(crate::dxf_ext::transparency_from_dxf(0x0200_0000), Some(90));
}
#[test]
fn block_definition_properties_and_constant_attdefs_roundtrip() {
let mut d = Drawing::new_metric();
let mut b = Block::new("Title");
(b.description, b.units, b.explodable) = ("Drawing title block".into(), 4, false);
let text = Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.5,
value: "ACME".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
};
let attdef = |tag: &str, invisible: bool, constant: bool| Attrib {
tag: tag.into(),
text: text.clone(),
invisible,
constant,
prompt: String::new(),
props: Default::default(),
};
for (i, a) in [attdef("COMPANY", false, true), attdef("SECRET", true, true), attdef("SHEET", false, false)].into_iter().enumerate() {
b.entities.push(Entity::new(Handle(0x500 + i as u64), EntityKind::AttDef(a)));
}
d.blocks.insert("Title".into(), std::sync::Arc::new(b));
d.blocks.insert("Plain".into(), std::sync::Arc::new(Block::new("Plain")));
d.bump_handseed(Handle(0x510));
let back = roundtrip(&d);
let t = back.block("Title").unwrap();
assert_eq!((t.description.as_str(), t.units, t.explodable), ("Drawing title block", 4, false));
let flags: Vec<(String, bool, bool)> = t
.entities
.iter()
.filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some((a.tag.clone(), a.invisible, a.constant)) } else { None })
.collect();
assert_eq!(flags, [("COMPANY".into(), false, true), ("SECRET".into(), true, true), ("SHEET".into(), false, false)]);
let p = back.block("Plain").unwrap();
assert_eq!((p.description.as_str(), p.units, p.explodable), ("", 0, true));
}
#[test]
fn reads_r12_style_polyline_and_paper_flag() {
let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n1\n0\nVERTEX\n8\n0\n10\n0\n20\n0\n0\nVERTEX\n8\n0\n10\n5\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n5\n20\n5\n0\nSEQEND\n0\nLINE\n67\n1\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "a.dxf").unwrap();
assert_eq!(d.model.len(), 1);
match &d.model.iter().next().unwrap().kind {
EntityKind::LwPolyline(p) => {
assert_eq!(p.vertices.len(), 3);
assert!(p.closed);
assert_eq!(p.vertices[1].bulge, 1.0);
}
_ => panic!(),
}
assert_eq!(d.layouts[0].entities.len(), 1);
}
#[test]
fn entity_transparency_roundtrips_as_group_440() {
// Issue #132: ByLayer writes nothing, ByBlock 0x01000000, a percentage its alpha | 0x02000000.
let ts = [Transparency::ByLayer, Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)];
let mut d = Drawing::new_imperial();
for (i, t) in ts.iter().enumerate() {
let y = i as f64;
let line = EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(1.0, y, 0.0) });
d.add(&Space::Model, Common { transparency: *t, ..Common::default() }, line).unwrap();
}
let text = write_dxf(&d);
assert_eq!(text.matches("\r\n440\r\n").count(), 4, "ByLayer writes no 440");
assert!(text.contains("\r\n440\r\n16777216\r\n"), "ByBlock");
assert!(text.contains("\r\n440\r\n33554585\r\n"), "40% is alpha 153");
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), ts);
// Values that are neither ByBlock nor "by alpha" read as ByLayer.
let text = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n440\n-1\n10\n0\n20\n0\n11\n1\n21\n1\n0\nLINE\n8\n0\n440\n33554432\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "a.dxf").unwrap();
assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), [Transparency::ByLayer, Transparency::Percent(90)]);
}
#[test]
fn hostile_dxf_does_not_panic() {
for t in [
"0\nSECTION\n2\nENTITIES\n0\nHATCH\n91\n999999999\n92\n2\n93\n99999\n0\nENDSEC\n0\nEOF\n",
"0\nSECTION\n2\nENTITIES\n0\nSPLINE\n71\n99\n40\n1\n10\n0\n20\n0\n0\nENDSEC\n0\nEOF\n",
"0\nSECTION\n2\nENTITIES\n0\nINSERT\n66\n1\n2\nX\n0\nATTRIB\n0\nENDSEC\n",
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n0\nINSERT\n2\nA\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
"0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n42\n5\n20\n1\n0\nENDSEC\n0\nEOF\n",
] {
if let Ok(d) = read(t.as_bytes(), "x.dxf") {
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let _ = d.extents(&Space::Model);
}
}
}
#[test]
fn svg_and_png_export() {
let d = sample();
let svg = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap();
assert!(svg.starts_with("<svg") && svg.contains("polyline"));
let png = write(&d, "a.png").unwrap();
assert_eq!(&png[1..4], b"PNG");
}
fn view_box(svg: &str) -> String {
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
svg[i..].split('"').next().unwrap_or("").to_string()
}
#[test]
fn image_export_frames_window() {
use cadcraft_geom::Bounds2;
let d = sample();
let win = Bounds2::new(Vec2::new(1.0, 2.0), Vec2::new(31.0, 22.0));
// SVG: the viewBox is exactly the window (no margin); the default still fits the extents.
let framed = String::from_utf8(write_framed(&d, "a.svg", Some(win)).unwrap()).unwrap();
let plain = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap();
assert_eq!(view_box(&framed), "0 0 30.000000 20.000000");
assert_ne!(view_box(&framed), view_box(&plain));
assert_eq!(plain, String::from_utf8(write_framed(&d, "a.svg", None).unwrap()).unwrap());
let ext = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
let m = ext.width().max(ext.height()) * 0.02;
assert_eq!(view_box(&plain), format!("0 0 {:.6} {:.6}", ext.width() + 2.0 * m, ext.height() + 2.0 * m));
// PNG: the window fills the 3:2 image exactly, centred.
let v = png_view(&ext, 2400, 1600, Some(win));
assert_eq!(v.center, Vec2::new(16.0, 12.0));
assert!((v.scale - 80.0).abs() < 1e-9);
assert_eq!(png_view(&ext, 2400, 1600, None), cadcraft_render::raster::View::fit(&ext, 2400, 1600, 0.05));
let png_framed = write_framed(&d, "a.png", Some(win)).unwrap();
assert_eq!(&png_framed[1..4], b"PNG");
assert_ne!(png_framed, write(&d, "a.png").unwrap());
// PDF: the window is fitted to the sheet instead of the extents.
let pdf_framed = write_framed(&d, "a.pdf", Some(win)).unwrap();
check_pdf(&pdf_framed);
assert_ne!(pdf_framed, write(&d, "a.pdf").unwrap());
// Non-image formats ignore the window.
assert_eq!(write_framed(&d, "a.dxf", Some(win)).unwrap(), write(&d, "a.dxf").unwrap());
}
#[test]
fn hostile_export_window_is_an_error() {
use cadcraft_geom::Bounds2;
let d = sample();
let raw = |x0: f64, y0: f64, x1: f64, y1: f64| Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
let bad = [
raw(f64::NAN, 0.0, 1.0, 1.0),
raw(0.0, 0.0, f64::INFINITY, 1.0),
raw(0.0, 0.0, 0.0, 1.0), // zero width
raw(0.0, 0.0, 1.0, 0.0), // zero height
raw(5.0, 0.0, 1.0, 1.0), // inverted
raw(-1e300, -1e300, 1e300, 1e300), // huge
raw(1e11, 1e11, 1e11 + 1e-6, 1e11 + 1e-6), // vanishingly small next to its coordinates
Bounds2::EMPTY,
];
for w in bad {
assert!(check_window(&w).is_err(), "{w:?}");
for name in ["a.png", "a.svg", "a.pdf"] {
assert!(write_framed(&d, name, Some(w)).is_err(), "{name} {w:?}");
}
}
assert!(check_window(&raw(-1e6, -1e6, 1e6, 1e6)).is_ok());
assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok());
}
/// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`.
fn check_pdf(bytes: &[u8]) -> String {
assert!(bytes.starts_with(b"%PDF-1.4"));
let find = |pat: &[u8]| bytes.windows(pat.len()).position(|w| w == pat);
let rfind = |pat: &[u8]| bytes.windows(pat.len()).rposition(|w| w == pat);
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string();
assert!(tail.trim_end().ends_with("%%EOF"));
let sx = rfind(b"startxref\n").unwrap();
let after = String::from_utf8_lossy(&bytes[sx + 10..]).to_string();
let xref_off: usize = after.lines().next().unwrap().trim().parse().unwrap();
assert!(bytes[xref_off..].starts_with(b"xref\n"));
let xref = String::from_utf8_lossy(&bytes[xref_off..]).to_string();
let mut lines = xref.lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
let objs = bytes.windows(7).filter(|w| w == b" 0 obj\n").count();
assert_eq!(count, objs + 1, "xref size = objects + free entry");
assert_eq!(bytes.windows(6).filter(|w| w == b"endobj").count(), objs);
let entries: Vec<&str> = lines.take(count).collect();
assert!(entries[0].starts_with("0000000000 65535 f"));
for (i, e) in entries.iter().enumerate().skip(1) {
let off: usize = e[..10].parse().unwrap();
assert!(bytes[off..].starts_with(format!("{i} 0 obj").as_bytes()), "object {i} offset");
}
assert!(xref.contains(&format!("/Size {count}")));
// The content stream's /Length matches its data.
let sp = find(b"/Length ").unwrap();
let after = String::from_utf8_lossy(&bytes[sp + 8..sp + 30]).to_string();
let len: usize = after.split(|c: char| !c.is_ascii_digit()).next().unwrap().parse().unwrap();
let start = find(b"stream\n").unwrap() + 7;
assert!(bytes[start + len..].starts_with(b"\nendstream"));
let compressed = find(b"/FlateDecode").is_some();
let data = &bytes[start..start + len];
if compressed {
String::from_utf8(miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap()).unwrap()
} else {
String::from_utf8(data.to_vec()).unwrap()
}
}
fn media_box(bytes: &[u8]) -> (f64, f64) {
let text = String::from_utf8_lossy(bytes).to_string();
let i = text.find("/MediaBox [0 0 ").unwrap() + 15;
let v: Vec<f64> = text[i..].split(']').next().unwrap().split_whitespace().map(|x| x.parse().unwrap()).collect();
(v[0], v[1])
}
#[test]
fn pdf_model_fitted_to_sheet() {
let d = sample();
let bytes = plot(&d, &Space::Model, &serde_json::json!({"paper": "A4", "landscape": true})).unwrap();
let content = check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "A4 landscape in points: {w} x {h}");
assert!(content.contains(" m\n") && content.contains(" l\n") && content.contains("S\n"));
// Walls layer is red; colour 7 prints black.
assert!(content.contains("1 0 0 RG"));
assert!(content.contains("0 0 0 RG"));
// Uncompressed output is plain text and also valid.
let raw = plot(&d, &Space::Model, &serde_json::json!({"paper": "Letter", "compress": false, "landscape": false})).unwrap();
let c2 = check_pdf(&raw);
assert!(!String::from_utf8_lossy(&raw).contains("FlateDecode"));
assert_eq!(media_box(&raw), (612.0, 792.0));
assert!(c2.contains("re W n"));
// write() picks PDF by extension.
assert!(write(&d, "x.pdf").unwrap().starts_with(b"%PDF"));
}
#[test]
fn pdf_layout_one_to_one_with_viewport() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
d.layouts[0].page.lineweights = true;
d.add(
&paper,
Common::default(),
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id: 2,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}),
)
.unwrap();
let bytes = plot(&d, &paper, &serde_json::json!({})).unwrap();
let content = check_pdf(&bytes);
// ANSI A landscape (inches → points).
assert_eq!(media_box(&bytes), (792.0, 612.0));
// Viewport border at 1:1: x from 1in to 9in = 72pt..648pt.
assert!(content.contains("72 72 m") || content.contains("72 72 l"), "{content}");
// Lineweights on: default 0.25 mm = 0.709 pt.
assert!(content.contains("0.709 w"));
let off = plot(&d, &paper, &serde_json::json!({"lineweights": false})).unwrap();
assert!(check_pdf(&off).contains("0 w"));
assert!(plot(&d, &Space::Paper("Nope".into()), &serde_json::json!({})).is_err());
assert!(plot(&d, &paper, &serde_json::json!({"paper": "Z9"})).is_err());
}
#[test]
fn pdf_hostile_options_and_empty() {
let d = Drawing::new_metric();
let bytes = plot(&d, &Space::Model, &serde_json::json!({})).unwrap();
check_pdf(&bytes);
let (w, h) = media_box(&bytes);
assert!((w - 841.89).abs() < 0.01 && (h - 595.276).abs() < 0.01, "metric default is A4 landscape");
let s = sample();
for o in [
serde_json::json!({"width": 1e308, "height": -5}),
serde_json::json!({"width": 1e308, "height": 1e308, "scale": 1e308, "fit": false}),
serde_json::json!({"scale": -1, "fit": false, "title": "a(b)\\c\u{e9}"}),
serde_json::json!(null),
serde_json::json!([1, 2]),
] {
check_pdf(&plot(&s, &Space::Model, &o).unwrap());
}
}
#[test]
fn dxf_roundtrips_page_setup() {
let mut d = sample();
let a3 = cadcraft_render::paper_size("A3").unwrap();
{
let p = &mut d.layouts[0].page;
p.paper = a3.name.into();
p.width_mm = a3.width_mm;
p.height_mm = a3.height_mm;
p.landscape = false;
p.margins_mm = [5.0, 6.0, 7.0, 8.0];
}
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
let p = &back.layout("Layout1").unwrap().page;
assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, false));
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(),
marks: Default::default(),
}
}
fn first<T>(d: &Drawing, f: impl Fn(&EntityKind) -> Option<T>) -> T {
d.model.iter().find_map(|e| f(&e.kind)).expect("entity")
}
fn roundtrip(d: &Drawing) -> Drawing {
read_dxf(write_dxf(d).as_bytes()).unwrap()
}
#[test]
fn viewport_frozen_layers_roundtrip() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
let vp = |id: u32, frozen: Vec<String>| {
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id,
locked: true,
frozen_layers: frozen,
layer_colors: Vec::new(),
clip: None,
on: true,
})
};
d.add(&paper, Common::default(), vp(2, vec!["Walls".into(), "A B".into()])).unwrap();
d.add(&paper, Common::default(), vp(3, Vec::new())).unwrap();
let back = roundtrip(&d);
let frozen: Vec<(u32, Vec<String>)> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) => Some((v.id, v.frozen_layers.clone())),
_ => None,
})
.collect();
assert!(frozen.contains(&(2, vec!["Walls".to_string(), "A B".to_string()])), "{frozen:?}");
assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}");
}
/// A layout with viewport 2 frozen on `Walls` and `A B` (both real layers), and its written DXF as
/// (code, value) pairs.
fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) {
let mut d = sample();
d.layers.push(Layer { name: "A B".into(), ..Layer::default() });
let paper = Space::Paper("Layout1".into());
let vp = EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id: 2,
locked: false,
frozen_layers: vec!["Walls".into(), "A B".into()],
layer_colors: Vec::new(),
clip: None,
on: true,
});
d.add(&paper, Common::default(), vp).unwrap();
let text = write_dxf(&d);
let lines: Vec<&str> = text.lines().collect();
let pairs = lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect();
(d, pairs)
}
/// The pair range of the VIEWPORT record whose group 69 is `id`.
fn vp_record(p: &[(String, String)], id: &str) -> std::ops::Range<usize> {
let starts: Vec<usize> = (0..p.len()).filter(|&i| p[i].0 == "0").collect();
for w in starts.windows(2) {
let r = w[0]..w[1];
if p[w[0]].1 == "VIEWPORT" && p[r.clone()].iter().any(|(c, v)| c == "69" && v.trim() == id) {
return r;
}
}
panic!("no VIEWPORT {id}");
}
fn layer_handle(p: &[(String, String)], name: &str) -> String {
let starts: Vec<usize> = (0..p.len()).filter(|&i| p[i].0 == "0").collect();
for w in starts.windows(2) {
let rec = &p[w[0]..w[1]];
if rec[0].1 == "LAYER" && rec.iter().any(|(c, v)| c == "2" && v == name) {
return rec.iter().find(|(c, _)| c == "5").unwrap().1.clone();
}
}
panic!("no LAYER {name}");
}
fn viewport_frozen(d: &Drawing, id: u32) -> Vec<String> {
d.layouts
.iter()
.flat_map(|l| l.entities.iter())
.find_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.id == id => Some(v.frozen_layers.clone()),
_ => None,
})
.expect("viewport")
}
fn join(p: &[(String, String)]) -> String {
p.iter()
.map(|(c, v)| {
format!(
"{c}
{v}
"
)
})
.collect()
}
#[test]
fn viewport_frozen_layers_written_as_341_handles() {
let (_, p) = frozen_vp_dxf();
let r = vp_record(&p, "2");
let rec = &p[r];
let h341: Vec<&str> = rec.iter().filter(|(c, _)| c == "341").map(|(_, v)| v.as_str()).collect();
assert_eq!(h341, vec![layer_handle(&p, "Walls").as_str(), layer_handle(&p, "A B").as_str()]);
let last341 = rec.iter().rposition(|(c, _)| c == "341").unwrap();
let x = rec.iter().position(|(c, _)| c == "1001").expect("xdata");
assert!(last341 < x, "341 must precede the xdata");
}
#[test]
fn viewport_frozen_layers_read_from_341_without_xdata() {
let (_, p) = frozen_vp_dxf();
let r = vp_record(&p, "2");
// Strip the CADCraft xdata, and add a 341 to a handle that names no layer.
let x = p[r.clone()].iter().position(|(c, _)| c == "1001").unwrap() + r.start;
let mut q: Vec<(String, String)> = p[..r.start].to_vec();
let mut head = p[r.start..x].to_vec();
head.push(("341".into(), "FFFFF".into()));
q.extend(head);
q.extend(p[r.end..].iter().cloned());
let back = read_dxf(join(&q).as_bytes()).unwrap();
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
}
#[test]
fn viewport_frozen_xdata_wins_over_341() {
let (_, p) = frozen_vp_dxf();
let r = vp_record(&p, "2");
// Point every 341 at layer 0: the xdata list must still be what is read.
let zero = layer_handle(&p, "0");
let mut q = p.clone();
for t in &mut q[r] {
if t.0 == "341" {
t.1 = zero.clone();
}
}
let back = read_dxf(join(&q).as_bytes()).unwrap();
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
}
#[test]
fn viewport_frozen_layers_survive_dwg() {
let (d, _) = frozen_vp_dxf();
let dwg = write(&d, "t.dwg").unwrap();
let back = read(&dwg, "t.dwg").unwrap();
assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]);
// Survives via the CADCraft xdata: the DWG converter does not yet carry group 341 itself.
}
#[test]
fn viewport_layer_colors_roundtrip() {
let mut d = sample();
let paper = Space::Paper("Layout1".into());
let vp = |id: u32, frozen: Vec<String>, colors: Vec<(String, Color)>| {
EntityKind::Viewport(Viewport {
center: Vec3::new(5.0, 4.0, 0.0),
width: 8.0,
height: 6.0,
view_center: Vec2::new(5.0, 2.5),
view_height: 12.0,
id,
locked: false,
frozen_layers: frozen,
layer_colors: colors,
clip: None,
on: true,
})
};
let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))];
d.add(&paper, Common::default(), vp(2, Vec::new(), colors.clone())).unwrap();
d.add(&paper, Common::default(), vp(3, Vec::new(), Vec::new())).unwrap();
// Frozen layers and colour overrides share one CADCraft xdata group.
d.add(&paper, Common::default(), vp(4, vec!["Walls".into()], colors.clone())).unwrap();
let back = roundtrip(&d);
let found: Vec<(u32, Vec<String>, Vec<(String, Color)>)> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) => Some((v.id, v.frozen_layers.clone(), v.layer_colors.clone())),
_ => None,
})
.collect();
assert!(found.contains(&(2, Vec::new(), colors.clone())), "{found:?}");
assert!(found.contains(&(3, Vec::new(), Vec::new())), "{found:?}");
assert!(found.contains(&(4, vec!["Walls".to_string()], colors)), "{found:?}");
}
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,
tol_zero_suppression: 8,
alt: true,
alt_factor: 0.03937,
alt_decimals: 3,
alt_post: "[<>]".into(),
alt_round: 0.005,
alt_unit: 6,
alt_zero_suppression: 4,
angular_unit: 1,
suppress_ext1: true,
suppress_ext2: true,
..DimStyle::default()
}
}
#[test]
fn dimstyle_roundtrips_every_field() {
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
d.dim_styles.push(full_dim_style());
let text = write_dxf(&d);
// Arrowheads are blocks referenced by handle (342/343/344) with DIMSAH set.
assert!(text.contains("_ArchTick") && text.contains("_DOT") && text.contains("_Open"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.dim_style("Mech").unwrap(), &full_dim_style());
assert_eq!(back.dim_style("Standard").unwrap(), &DimStyle::default());
// Generated arrowhead blocks are not imported as user blocks, so they never pile up.
assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{:?}", back.blocks.keys().collect::<Vec<_>>());
let again = roundtrip(&back);
assert_eq!(again.dim_styles, back.dim_styles);
assert_eq!(write_dxf(&again).matches("AcDbBlockBegin").count(), write_dxf(&back).matches("AcDbBlockBegin").count());
}
#[test]
fn user_block_arrowheads_are_referenced_not_regenerated() {
let mut d = Drawing::new_imperial();
let mut b = Block::new("MyArrow");
b.entities.push(Entity::new(Handle(0x60), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.5 })));
d.blocks.insert("MyArrow".into(), std::sync::Arc::new(b));
d.dim_styles.push(DimStyle { name: "U".into(), arrow_block: "MyArrow".into(), ..DimStyle::default() });
let back = roundtrip(&d);
assert_eq!(back.dim_style("U").unwrap().arrow_block, "MyArrow");
assert!(back.block("MyArrow").is_some());
assert_eq!(back.blocks.len(), d.blocks.len());
}
#[test]
fn dimension_overrides_roundtrip_as_dstyle_xdata() {
let mut d = Drawing::new_imperial();
d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() });
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
let raw = serde_json::json!({
"arrowSize": 0.5,
"dimLineColor": 3,
"textStyle": "Romans",
"arrowBlock1": "_Dot",
"arrowBlock": "",
"decimalSeparator": ",",
"suppressExt1": true,
"post": "<> mm",
"decimals": 2,
"textJust": 1,
"altFactor": 25.4,
});
dm.overrides = raw.as_object().unwrap().clone();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("DSTYLE"));
let back = read_dxf(text.as_bytes()).unwrap();
let got = first(&back, |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
// Values come back canonical (colours as colour objects); the effective style is identical.
assert_eq!(got.overrides.len(), dm.overrides.len(), "{:?}", got.overrides);
let base = DimStyle::default();
assert_eq!(base.with_overrides(&got.overrides), base.with_overrides(&dm.overrides));
assert_eq!(got.overrides.get("dimLineColor"), Some(&serde_json::to_value(Color::Index(3)).unwrap()));
// Canonical values round-trip exactly.
let again = first(&roundtrip(&back), |k| if let EntityKind::Dimension(x) = k { Some(x.clone()) } else { None });
assert_eq!(again.overrides, got.overrides);
}
/// A drawing with `n` lines, circles and polylines on a grid (issue #57's tests).
fn many_entities(n: usize) -> Drawing {
let mut d = Drawing::new_imperial();
for i in 0..n {
let (x, y) = ((i % 300) as f64 * 10.0, (i / 300) as f64 * 10.0);
let kind = match i % 3 {
0 => EntityKind::Line(Line { a: Vec3::new(x, y, 0.0), b: Vec3::new(x + 4.0, y + (i % 7) as f64, 0.0) }),
1 => EntityKind::Circle(Circle { center: Vec3::new(x, y, 0.0), radius: 1.0 + (i % 5) as f64 }),
_ => EntityKind::LwPolyline(LwPolyline {
vertices: (0..8).map(|k| PolyVertex::new(Vec2::new(x + f64::from(k), y + (k % 3) as f64))).collect(),
closed: false,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
};
d.add(&Space::Model, Common::default(), kind).unwrap();
}
d
}
/// An AutoCAD 2010 (AC1024) DWG of `n` entities, made through the DWG bridge.
#[cfg(not(target_arch = "wasm32"))]
fn ac1024_dwg(n: usize) -> Vec<u8> {
let dxf = write_dxf(&many_entities(n)).replacen("AC1015", "AC1024", 1);
let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1024"));
dwg
}
/// Issue #57: an AutoCAD 2010 DWG with tens of thousands of entities opens completely, and its
/// DXF rendition stays far inside the limits (about 6 bytes of DXF and 0.4 group codes per DWG
/// byte), so a 10 MB file is nowhere near them.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn ac1024_dwg_with_many_entities_opens() {
let n = 30_000;
let dwg = ac1024_dwg(n);
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
let tags = cadcraft_dxf::parse(&dxf).unwrap().len();
assert!(dxf.len() < dwg.len() * 20, "DXF {} bytes from {} DWG bytes", dxf.len(), dwg.len());
assert!(tags < dwg.len(), "{tags} group codes from {} DWG bytes", dwg.len());
let d = read(&dwg, "big.dwg").unwrap();
assert_eq!(d.entity_count(), n);
}
/// Issue #57: oversized input is rejected early, with a message naming the limit, never a panic.
/// A conversion that runs away (a damaged or misread DWG can make the reader produce a drawing
/// thousands of times larger than the file) stops at the DXF size limit instead of filling memory
/// and failing later with "file too large"; the DWG size is checked before reading; the DXF
/// group-code count before parsing.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn oversized_dwg_and_dxf_are_rejected_early() {
let dwg = ac1024_dwg(3000);
let full = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().len();
assert!(full > 400_000, "{full}");
let limits = cadcraft_dwg::Limits { max_dxf_bytes: 300_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: converting this 0."), "{e}");
assert!(e.contains("MB file produced more than 0.3 MB of drawing data (the limit)"), "{e}");
let limits = cadcraft_dwg::Limits { max_dwg_bytes: 100_000, ..cadcraft_dwg::Limits::DEFAULT };
let e = cadcraft_dwg::dwg_to_dxf_with(&dwg, limits).unwrap_err();
assert!(e.starts_with("DWG: the file is 0.") && e.ends_with("MB, larger than the 0.1 MB CADCraft opens"), "{e}");
// Lines that aren't even group codes: the size is reported before anything is parsed.
let text = "not a group code\nx\n".repeat(60);
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads");
}
#[test]
fn dimension_associativity_roundtrips() {
let mut d = Drawing::new_imperial();
let c = Common::default;
let l1 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(0.0, 5.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(10.0, 0.0, 0.0), b: Vec3::new(10.0, 5.0, 0.0) })).unwrap();
let ci = d.add(&Space::Model, c(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0 })).unwrap();
let pl = d
.add(
&Space::Model,
c(),
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::new(30.0, 0.0)), PolyVertex::new(Vec2::new(32.0, 0.0)), PolyVertex::new(Vec2::new(32.0, 3.0))],
closed: false,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
let mut lin = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
lin.assoc = vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Start },
];
let mut ali = dim(DimKind::Aligned, Vec3::new(22.0, 0.0, 0.0), Vec3::new(32.0, 3.0, 0.0));
ali.assoc = vec![
DimAssoc { point: "p13".into(), handle: ci, snap: AssocSnap::OnCircle { angle: 0.0 } },
DimAssoc { point: "p14".into(), handle: pl, snap: AssocSnap::Vertex { index: 2 } },
DimAssoc { point: "defpt".into(), handle: l1, snap: AssocSnap::Intersection { other: l2 } },
];
let mut rad = dim(DimKind::Radius, Vec3::new(20.0, 0.0, 0.0), Vec3::ZERO);
rad.assoc = vec![DimAssoc { point: "defpt".into(), handle: ci, snap: AssocSnap::Center }];
let hl = d.add(&Space::Model, c(), EntityKind::Dimension(lin.clone())).unwrap();
let ha = d.add(&Space::Model, c(), EntityKind::Dimension(ali.clone())).unwrap();
let hr = d.add(&Space::Model, c(), EntityKind::Dimension(rad.clone())).unwrap();
let text = write_dxf(&d);
assert!(text.contains("DIMASSOC") && text.contains("ACAD_DIMASSOC") && text.contains("AcDbOsnapPointRef"));
let back = read_dxf(text.as_bytes()).unwrap();
let assoc_of = |d: &Drawing, h: Handle| match &d.entity(h).unwrap().kind {
EntityKind::Dimension(x) => x.assoc.clone(),
_ => panic!(),
};
assert_eq!(assoc_of(&back, hl), lin.assoc);
assert_eq!(assoc_of(&back, ha), ali.assoc);
assert_eq!(assoc_of(&back, hr), rad.assoc);
// Entities owning reactors still land in model space.
assert_eq!(back.model.len(), d.model.len());
assert!(back.layouts.iter().all(|l| l.entities.is_empty()));
// Without CADCraft's xdata (a file from another writer), the standard DIMASSOC objects
// still give the extension-line links.
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
let mut stripped = Vec::new();
let mut skipping = false;
for t in tags {
if t.code == 1001 {
skipping = t.str() == "CADCRAFT";
} else if t.code < 1000 {
skipping = false;
}
if !skipping {
stripped.push(t);
}
}
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
assert_eq!(assoc_of(&foreign, hl), lin.assoc);
let fa = assoc_of(&foreign, ha);
assert_eq!(fa.len(), 2);
assert_eq!(fa[1], ali.assoc[1]);
assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9));
assert!(assoc_of(&foreign, hr).is_empty(), "radial links are CADCraft-only");
}
fn parametric_sample(d: &mut Drawing) -> (Vec<Constraint>, Parametric) {
let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(4.0, 0.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(4.0, 0.0, 0.0), b: Vec3::new(4.0, 3.0, 0.0) })).unwrap();
let constraints = vec![
Constraint { id: 1, kind: ConstraintKind::Horizontal, refs: vec![GeomRef::whole(l1)], name: String::new(), expr: String::new() },
Constraint {
id: 2,
kind: ConstraintKind::Coincident,
refs: vec![GeomRef::new(l1, Sub::End), GeomRef::new(l2, Sub::Start)],
name: String::new(),
expr: String::new(),
},
Constraint {
id: 3,
kind: ConstraintKind::Distance(DistAxis::Vertical),
refs: vec![GeomRef::new(l2, Sub::Start), GeomRef::new(l2, Sub::End)],
name: "d1".into(),
expr: "width/2 + 1".into(),
},
Constraint {
id: 4,
kind: ConstraintKind::Angular,
refs: vec![GeomRef::whole(l1), GeomRef::whole(l2)],
name: "ang1".into(),
expr: "90".into(),
},
Constraint {
id: 5,
kind: ConstraintKind::Fix,
refs: vec![GeomRef::new(l2, Sub::Vertex(7)), GeomRef::new(l2, Sub::Segment(3))],
name: String::new(),
expr: String::new(),
},
];
// Long text (chunk boundaries inside strings, spaces at the edges), backslashes that
// would look like DXF `\U+` escapes, and non-ASCII text.
let long = format!(" {} \\U+0041 \\\\ \"quoted\" Ünïcødé ✓ {} ", "word ".repeat(80), "x".repeat(300));
let parametric = Parametric {
parameters: vec![
Parameter { name: "width".into(), expr: "12.5".into(), description: long },
Parameter { name: "h".into(), expr: "width*0.4".into(), description: String::new() },
],
settings: ParametricSettings {
infer: true,
distance_tolerance: 0.125,
angle_tolerance: 2.5,
auto_types: vec!["Parallel".into(), "Tangent".into()],
bars_visible: false,
bar_exceptions: vec![l1],
dims_visible: false,
dim_exceptions: vec![l2],
bar_transparency: 30,
},
};
d.constraints = constraints.clone();
d.parametric = parametric.clone();
(constraints, parametric)
}
#[test]
fn constraints_and_parameters_roundtrip_exactly() {
let mut d = Drawing::new_imperial();
let (constraints, parametric) = parametric_sample(&mut d);
let text = write_dxf(&d);
assert!(text.contains("CADCRAFT_CONSTRAINTS") && text.contains("XRECORD"));
assert!(!text.contains("\\U+0041"), "no DXF unicode escape may appear in the payload");
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.constraints, constraints);
assert_eq!(back.parametric, parametric);
let again = roundtrip(&back);
assert_eq!(again.constraints, constraints);
assert_eq!(again.parametric, parametric);
// Drawings without parametric data carry no record.
assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_CONSTRAINTS"));
}
#[test]
fn layer_states_roundtrip_next_to_constraints() {
let mut d = Drawing::new_imperial();
let (constraints, _) = parametric_sample(&mut d);
d.layers.push(Layer {
name: "Wände".into(),
color: Color::True(cadcraft_color::Rgb(200, 10, 40)),
transparency: 30,
description: "walls \\ hidden".into(),
..Layer::default()
});
d.layer_states.push(LayerState { name: "Plot".into(), layers: d.layers.clone() });
let mut off = d.layers.clone();
for l in &mut off {
(l.on, l.frozen, l.locked, l.plot) = (false, true, true, false);
}
d.layer_states.push(LayerState { name: "All off".into(), layers: off });
let text = write_dxf(&d);
assert!(text.contains("CADCRAFT_LAYERSTATES"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.layer_states, d.layer_states);
assert_eq!(back.constraints, constraints);
assert_eq!(roundtrip(&back).layer_states, d.layer_states);
#[cfg(not(target_arch = "wasm32"))]
assert_eq!(read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap().layer_states, d.layer_states);
// Drawings without saved layer states carry no record.
assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_LAYERSTATES"));
}
fn table_sample() -> Table {
let cell = |t: &str| TableCell { text: t.into(), ..Default::default() };
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,
..Table::default()
}
}
#[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, ..TableStyle::default() });
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));
}
/// Non-ASCII text in every kind of string the writer emits: CJK, Latin-1 and an astral
/// character (issue #164).
const INTL: &str = "图号 İø Ø 😀";
fn intl_text(value: &str) -> Text {
Text {
insert: Vec3::new(1.0, 1.0, 0.0),
align_pt: None,
height: 2.5,
value: value.into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
}
}
fn intl_sample() -> Drawing {
let mut d = Drawing::new_metric();
let layer = format!("层 {INTL}");
let block = format!("块 {INTL}");
d.layers.push(Layer { name: layer.clone(), color: Color::Index(3), ..Layer::default() });
let on = Common { layer: layer.clone(), ..Common::default() };
d.add(&Space::Model, on.clone(), EntityKind::Text(intl_text(INTL))).unwrap();
d.add(
&Space::Model,
on.clone(),
EntityKind::MText(MText {
insert: Vec3::new(5.0, 5.0, 0.0),
height: 2.5,
width: 30.0,
attach: 1,
rotation: 0.0,
style: "Standard".into(),
contents: format!("{INTL}\\P第二行 Ünïcødé"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
)
.unwrap();
let def =
Attrib {
tag: "图号".into(), text: intl_text("默认"), invisible: false, constant: false, prompt: String::new(), props: Default::default()
};
let mut b = Block::new(&block);
b.entities.push(Entity::new(Handle(0x50), EntityKind::AttDef(def)));
d.blocks.insert(block.clone(), std::sync::Arc::new(b));
let att =
Attrib { tag: "图号".into(), text: intl_text(INTL), invisible: false, constant: false, prompt: String::new(), props: Default::default() };
d.add(
&Space::Model,
on,
EntityKind::Insert(Insert {
block,
insert: Vec3::new(2.0, 2.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![att],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
)
.unwrap();
d
}
fn assert_intl(back: &Drawing) {
let layer = format!("层 {INTL}");
let block = format!("块 {INTL}");
assert_eq!(back.layer(&layer).map(|l| l.color), Some(Color::Index(3)));
let text = first(back, |k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None });
assert_eq!(text, INTL);
let mtext = first(back, |k| if let EntityKind::MText(t) = k { Some(t.contents.clone()) } else { None });
assert_eq!(mtext, format!("{INTL}\\P第二行 Ünïcødé"));
let ins = first(back, |k| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None });
assert_eq!(ins.block, block);
assert_eq!(ins.attribs.len(), 1);
assert_eq!(ins.attribs[0].tag, "图号");
assert_eq!(ins.attribs[0].text.value, INTL);
let b = back.block(&block).unwrap();
let def = b.entities.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.clone()) } else { None }).unwrap();
assert_eq!(def.tag, "图号");
assert_eq!(def.text.value, "默认");
assert!(back.model.iter().all(|e| e.common.layer == layer));
}
/// The writer declares R2000 (`AC1015`, `ANSI_1252`), where text is in the code page and not
/// UTF-8, so every non-ASCII character must be a `\U+XXXX` escape and the file pure ASCII.
#[test]
fn non_ascii_text_is_escaped_for_r2000_and_roundtrips() {
let d = intl_sample();
let text = write_dxf(&d);
assert!(text.is_ascii(), "an AC1015 DXF must not contain UTF-8 bytes");
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
let header = |name: &str| tags.windows(2).find(|w| w[0].code == 9 && w[0].str() == name).map(|w| w[1].str());
assert_eq!(header("$ACADVER").as_deref(), Some("AC1015"));
assert_eq!(header("$DWGCODEPAGE").as_deref(), Some("ANSI_1252"));
// CJK, Latin-1 and a surrogate pair for the astral character.
assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+00D8 \\U+D83D\\U+DE00"));
let back = read_dxf(text.as_bytes()).unwrap();
assert_intl(&back);
// A second save writes the same text.
assert_eq!(write_dxf(&back).matches("\\U+").count(), text.matches("\\U+").count());
}
/// Real R2007+ files are UTF-8 and older ones are in their code page; both still read.
#[test]
fn reads_utf8_and_code_page_text() {
let file = |ver: &str, value: &[u8]| {
let mut b =
format!("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\n{ver}\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n0\n20\n0\n40\n1\n1\n")
.into_bytes();
b.extend_from_slice(value);
b.extend_from_slice(b"\n0\nENDSEC\n0\nEOF\n");
b
};
let value = |bytes: &[u8]| first(&read_dxf(bytes).unwrap(), |k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None });
assert_eq!(value(&file("AC1032", INTL.as_bytes())), INTL);
assert_eq!(value(&file("AC1015", b"Caf\xe9 \xd8 \\U+56FE\\U+53F7")), "Café Ø 图号");
}
/// Saving as DWG goes through this DXF and acadrust, which decodes the escapes.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_roundtrip_keeps_non_ascii_text() {
let back = read(&write(&intl_sample(), "x.dwg").unwrap(), "x.dwg").unwrap();
assert_intl(&back);
}
#[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, ..TableStyle::default() });
d.add(&Space::Model, Common::default(), EntityKind::Table(table_sample())).unwrap();
let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 30.0, 0.0), b: Vec3::new(0.0, 35.0, 0.0) })).unwrap();
let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(8.0, 30.0, 0.0), b: Vec3::new(8.0, 35.0, 0.0) })).unwrap();
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::new(0.0, 30.0, 0.0), Vec3::new(8.0, 30.0, 0.0));
dm.style = "Mech".into();
dm.assoc = vec![
DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::Start },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Start },
];
dm.overrides = serde_json::json!({"arrowBlock2": "_BoxFilled", "textColor": 2, "decimals": 1}).as_object().unwrap().clone();
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
let ci = d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(20.0, 30.0, 0.0), radius: 2.0 })).unwrap();
let mut al = dim(DimKind::Aligned, Vec3::new(22.0, 30.0, 0.0), Vec3::new(8.0, 35.0, 0.0));
al.assoc = vec![
DimAssoc { point: "p13".into(), handle: ci, snap: AssocSnap::OnCircle { angle: 0.0 } },
DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::Intersection { other: l1 } },
];
d.add(&Space::Model, Common::default(), EntityKind::Dimension(al)).unwrap();
parametric_sample(&mut d);
d
}
#[test]
fn extension_sample_roundtrips_and_can_be_exported() {
let d = extension_sample();
let text = write_dxf(&d);
if let Ok(path) = std::env::var("CADCRAFT_DXF_OUT") {
std::fs::write(path, &text).unwrap();
}
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.model.len(), d.model.len());
assert_eq!(back.constraints, d.constraints);
assert_eq!(back.dim_style("Mech"), d.dim_style("Mech"));
let kinds = |d: &Drawing| d.model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>();
assert_eq!(kinds(&back), kinds(&d));
}
/// DXF → DWG (acadrust) → DXF keeps what the DWG bridge supports: dimension styles with
/// arrow blocks, override and associativity xdata, the constraint XRECORD, tables and text
/// style flags. (TABLESTYLE objects do not survive the bridge.)
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_roundtrip_keeps_extension_data() {
let d = extension_sample();
let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap();
// The bridge loses DIMFXL and DIMJOGANG (0 and its 5° minimum come back).
let norm = |s: Option<&DimStyle>| s.cloned().map(|s| DimStyle { fixed_ext_length: 1.0, jog_angle: 45.0, ..s });
assert_eq!(norm(back.dim_style("Mech")), norm(d.dim_style("Mech")));
assert_eq!(back.constraints, d.constraints);
assert_eq!(back.parametric, d.parametric);
let dims =
|d: &Drawing| d.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect::<Vec<_>>();
let (a, b) = (dims(&d), dims(&back));
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.assoc, y.assoc);
let st = DimStyle::default();
assert_eq!(st.with_overrides(&x.overrides), st.with_overrides(&y.overrides));
}
assert_eq!(first(&back, |k| if let EntityKind::Table(x) = k { Some(x.clone()) } else { None }), table_sample());
let r = back.text_style("Romans").unwrap();
assert!(r.backwards && r.annotative);
}
#[test]
fn mleader_is_written_as_multileader() {
let mut d = Drawing::new_metric();
let text = cadcraft_doc::MText {
insert: Vec3::new(12.0, 5.0, 0.0),
height: 2.5,
width: 0.0,
attach: 1,
rotation: 0.0,
contents: "Note".into(),
style: "Standard".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
let m = cadcraft_doc::MLeader {
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0)]],
landing: Vec3::new(10.0, 5.0, 0.0),
dogleg: 2.0,
text: Some(text),
style: "Standard".into(),
arrow_size: 2.5,
spline: false,
block: None,
};
d.add(&Space::Model, Default::default(), EntityKind::MLeader(m.clone())).unwrap();
// MULTILEADER is a 2007 entity (R2000/2004 files get LEADER + MTEXT).
let back =
crate::read(&crate::write_with(&d, "x.dxf", &crate::SaveOptions { version: Some(crate::Version::R2007), binary: false }).unwrap(), "x.dxf")
.unwrap();
assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MLeader(x) if *x == m)));
assert!(!back.model.iter().any(|e| matches!(e.kind, EntityKind::Leader(_) | EntityKind::MText(_))));
}
#[test]
fn attdef_prompt_survives_dxf_roundtrip() {
let mut d = Drawing::new_metric();
let text = Text {
insert: Vec3::ZERO,
align_pt: None,
height: 2.0,
value: "X".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
};
let attdef = |prompt: &str| {
EntityKind::AttDef(Attrib {
tag: "TAG1".into(),
text: text.clone(),
invisible: false,
constant: false,
prompt: prompt.into(),
props: Default::default(),
})
};
d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap();
d.add(&Space::Model, Default::default(), attdef("")).unwrap();
let back = roundtrip(&d);
let prompts: Vec<&str> =
back.model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.prompt.as_str()) } else { None }).collect();
assert_eq!(prompts, vec!["Enter value", ""]);
}
#[test]
fn dimension_text_rotation_roundtrips() {
let mut d = Drawing::new_imperial();
let mut dm = dim(DimKind::Linear { rotation: 0.0 }, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0));
dm.text_rotation = 30.0_f64.to_radians();
let h = d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm.clone())).unwrap();
let back = roundtrip(&d);
let EntityKind::Dimension(x) = &back.entity(h).unwrap().kind else { panic!("dimension expected") };
assert!((x.text_rotation - dm.text_rotation).abs() < 1e-9, "got {}", x.text_rotation);
// The anonymous block that renders the reopened dimension carries the same text angle.
let b = x.block.as_deref().and_then(|n| back.block(n)).expect("dimension block");
let rot = b.entities.iter().find_map(|e| if let EntityKind::MText(t) = &e.kind { Some(t.rotation) } else { None });
assert!(rot.is_some_and(|r| (r - dm.text_rotation).abs() < 1e-9), "block text rotation {rot:?}");
}
/// Wipeout clip vertices are in image pixel space (origin top-left, y down): an asymmetric
/// boundary must keep its orientation, both through our reader and in the file itself.
#[test]
fn wipeout_boundary_keeps_its_orientation() {
let mut d = Drawing::new_imperial();
// A triangle with its apex at the top.
let boundary = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 4.0)];
d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap();
let text = write_dxf(&d);
// The apex (0, 4) is at the top-left corner of the image: pixel (-0.5, -0.5).
let pts: Vec<(f64, f64)> = {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let mut v = Vec::new();
for i in 0..lines.len().saturating_sub(3) {
if lines[i] == "14" && lines[i + 2] == "24" {
v.push((lines[i + 1].parse().unwrap(), lines[i + 3].parse().unwrap()));
}
}
v
};
assert!(pts.iter().any(|&(x, y)| (x + 0.5).abs() < 1e-9 && (y + 0.5).abs() < 1e-9), "{pts:?}");
let back = roundtrip(&d);
let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None });
assert_eq!(&got[..3], &boundary[..]);
}
#[test]
fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() {
let mut d = Drawing::new_metric();
// Arc of radius 10 centred off the origin, so a missing centre would read back as zero.
let mut al = dim(DimKind::ArcLength, Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 15.0, 0.0));
al.p15 = Vec3::new(5.0, 5.0, 0.0);
let h = d.add(&Space::Model, Default::default(), EntityKind::Dimension(al)).unwrap();
let text = write_dxf(&d);
// A standard aligned DIMENSION record (type 1 | 32), so other programs can read it.
let lines: Vec<&str> = text.lines().map(str::trim).collect();
assert!(lines.windows(2).any(|w| w == ["70", "33"]) && !lines.windows(2).any(|w| w == ["70", "40"]), "{text}");
let back = read_dxf(text.as_bytes()).unwrap();
match &back.entity(h).unwrap().kind {
EntityKind::Dimension(x) => {
assert!(matches!(x.kind, DimKind::ArcLength));
assert_eq!(x.p15, Vec3::new(5.0, 5.0, 0.0));
}
_ => panic!("not a dimension"),
}
// And through DWG.
let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap();
let back = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap();
let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect();
assert_eq!(dims.len(), 1);
assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind);
assert!((dims[0].p15 - Vec3::new(5.0, 5.0, 0.0)).len() < 1e-9);
}
/// Two gradient hatches (true colours, shifted, rotated; index colours, centered) and a plain
/// solid hatch over the same square.
fn gradient_sample() -> (Drawing, Vec<Option<Gradient>>) {
let mut d = Drawing::new_metric();
let square = vec![
PolyVertex::new(Vec2::ZERO),
PolyVertex::new(Vec2::new(10.0, 0.0)),
PolyVertex::new(Vec2::new(10.0, 10.0)),
PolyVertex::new(Vec2::new(0.0, 10.0)),
];
let gradients = vec![
Some(Gradient {
name: "LINEAR".into(),
color1: Color::True(cadcraft_color::Rgb(255, 0, 0)),
color2: Color::True(cadcraft_color::Rgb(0, 0, 255)),
angle: 30f64.to_radians(),
centered: false,
}),
Some(Gradient { name: "CURVED".into(), color1: Color::Index(5), color2: Color::ByLayer, angle: 0.0, centered: true }),
None,
];
for g in &gradients {
let h = Hatch {
pattern: "SOLID".into(),
solid: true,
loops: vec![HatchLoop { vertices: square.clone(), outer: true, sources: Vec::new() }],
scale: 1.0,
angle: 0.0,
associative: false,
style: 0,
elevation: 0.0,
gradient: g.clone(),
origin: Vec2::ZERO,
background: None,
pattern_lines: Vec::new(),
};
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
}
(d, gradients)
}
fn hatches(d: &Drawing) -> Vec<Hatch> {
d.model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).collect()
}
/// Issue #80: a DXF save/reopen keeps each hatch's gradient (name, both colours, angle and
/// centered setting) as native HATCH groups 450–470, and a plain solid stays plain.
#[test]
fn gradient_hatch_roundtrips_through_dxf() {
let (d, gradients) = gradient_sample();
let text = write_dxf(&d);
for code in ["450", "451", "452", "453", "460", "461", "462", "463", "421", "470"] {
assert!(text.lines().any(|l| l.trim() == code), "missing group {code}");
}
let back = read_dxf(text.as_bytes()).unwrap();
let (a, b) = (hatches(&d), hatches(&back));
assert_eq!(b.len(), gradients.len());
for ((x, y), g) in a.iter().zip(&b).zip(&gradients) {
assert_eq!(&y.gradient, g);
assert_eq!(x.loops, y.loops);
}
assert_eq!(roundtrip(&back).model.iter().map(|e| &e.kind).collect::<Vec<_>>(), back.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
}
/// R2000 readers and DWG conversions drop the native groups; CADCraft's xdata copy restores the
/// gradient. When native groups are present (another program may have edited them) they win.
#[test]
fn gradient_falls_back_to_xdata_and_native_groups_win() {
let (d, gradients) = gradient_sample();
let text = write_dxf(&d);
// Strip groups 450–470 and 63/421 (pairs of lines) from every entity.
let lines: Vec<&str> = text.lines().collect();
let mut stripped = String::new();
for pair in lines.chunks(2) {
let code = pair.first().map(|c| c.trim().parse::<i32>().unwrap_or(-1)).unwrap_or(-1);
if (450..=470).contains(&code) || code == 63 || code == 421 {
continue;
}
for l in pair {
stripped.push_str(l);
stripped.push('\n');
}
}
assert!(!stripped.lines().any(|l| l.trim() == "450"));
let back = read_dxf(stripped.as_bytes()).unwrap();
assert_eq!(hatches(&back).into_iter().map(|h| h.gradient).collect::<Vec<_>>(), gradients);
// Native groups edited elsewhere (new angle, colour, name) take precedence over stale xdata.
let edited = text.replacen("470\r\nLINEAR", "470\r\nSPHERICAL", 1).replacen("421\r\n16711680", "421\r\n65280", 1);
assert_ne!(edited, text);
let back = read_dxf(edited.as_bytes()).unwrap();
let g = hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap();
assert_eq!(g.name, "SPHERICAL");
assert_eq!(g.color1, Color::True(cadcraft_color::Rgb(0, 255, 0)));
}
/// Issue #80: DWG saves (R2000, which has no native gradient fields) keep the gradient too.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gradient_hatch_roundtrips_through_dwg() {
let (d, gradients) = gradient_sample();
let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap();
let hs = hatches(&back);
assert_eq!(hs.iter().map(|h| h.gradient.clone()).collect::<Vec<_>>(), gradients);
assert_eq!(hs.iter().map(|h| h.loops.clone()).collect::<Vec<_>>(), hatches(&d).into_iter().map(|h| h.loops).collect::<Vec<_>>());
}
#[test]
fn hostile_gradient_groups_never_panic() {
let hatch = |tail: &str| {
format!(
"0\nSECTION\n2\nENTITIES\n0\nHATCH\n2\nSOLID\n70\n1\n91\n1\n92\n2\n72\n0\n73\n1\n93\n3\n10\n0\n20\n0\n10\n1\n20\n0\n10\n1\n20\n1\n98\n0\n{tail}0\nENDSEC\n0\nEOF\n"
)
};
let cases = [
// Native: absurd counts, colour records past two, out-of-range ACI/true colours, NaN.
hatch(
"450\n1\n453\n2147483647\n460\nnan\n461\ninf\n463\n0\n63\n99999\n421\n-1\n463\n1\n63\n-32768\n421\n99999999999\n463\n2\n63\n3\n463\n7\n470\n\n",
),
// Native flag only, or a non-gradient flag, or groups out of order.
hatch("450\n1\n"),
hatch("450\n-7\n470\nX\n"),
hatch("421\n255\n63\n1\n463\n0\n450\n1\n470\nLINEAR\n"),
// Xdata: missing values, junk colour names, huge numbers.
hatch("1001\nCADCRAFT\n1000\nGRADIENT\n"),
hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1000\nX\n1040\n1e308\n1070\n-1\n1000\nnot a colour\n1000\n999,999,999\n"),
hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1040\nnan\n1001\nACAD\n1000\n\n"),
];
for t in &cases {
let d = read(t.as_bytes(), "x.dxf").unwrap();
for h in hatches(&d) {
if let Some(g) = &h.gradient {
assert!(g.angle.is_finite());
}
}
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
let back = read_dxf(write_dxf(&d).as_bytes()).unwrap();
assert_eq!(
hatches(&back).into_iter().map(|h| h.gradient).collect::<Vec<_>>(),
hatches(&d).into_iter().map(|h| h.gradient).collect::<Vec<_>>()
);
}
}
/// The native 470 group keeps a long name whole; the xdata copy is cut to 255 bytes at a
/// character boundary.
#[test]
fn long_gradient_name_is_cut_only_in_xdata() {
let name = "Ä".repeat(200);
let mut d = Drawing::new_metric();
let mut h = hatches(&gradient_sample().0).into_iter().next().unwrap();
if let Some(g) = &mut h.gradient {
g.name = name.clone();
}
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
let text = write_dxf(&d);
let lines: Vec<&str> = text.lines().collect();
// Non-ASCII text is written as \U+ escapes (R2000 files are ANSI).
assert!(lines.contains(&"\\U+00C4".repeat(200).as_str()));
assert!(lines.contains(&"\\U+00C4".repeat(127).as_str()));
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap().name, name);
}
#[test]
fn wipeout_survives_dxf_roundtrip() {
let mut d = Drawing::new_imperial();
let boundary = vec![Vec2::new(2.0, 3.0), Vec2::new(12.0, 3.0), Vec2::new(12.0, 13.0), Vec2::new(2.0, 13.0)];
d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap();
let back = roundtrip(&d);
let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None });
// The DXF polygon is closed, so the first vertex is repeated at the end.
assert_eq!(got.len(), 5);
assert_eq!(&got[..4], &boundary[..]);
assert_eq!(got[4], boundary[0]);
}
#[test]
fn autocad_header_dim_variables_are_not_overrides() {
// AutoCAD writes the current style's values as $DIM* header variables; reading them back must
// not turn them into style overrides for new dimensions (issue #66).
use crate::dxf_ext::{K, dim_code};
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());
d.header.set_str("DIMSTYLE", "Mech");
d.header.set_f64("DIMSCALE", full_dim_style().scale);
let st = serde_json::to_value(full_dim_style()).unwrap();
let mut vars = String::new();
for (var, field) in DIMVARS {
let Some((_, kind)) = dim_code(field) else { continue };
let v = &st[*field];
let (code, val) = match kind {
K::Real => (40, v.as_f64().unwrap().to_string()),
K::Int => (70, v.as_i64().unwrap().to_string()),
K::Bool => (70, i64::from(v.as_bool().unwrap()).to_string()),
K::Color => (70, serde_json::from_value::<Color>(v.clone()).unwrap().to_aci().to_string()),
K::Char => (70, u32::from(v.as_str().unwrap().chars().next().unwrap()).to_string()),
K::Str => (1, v.as_str().unwrap().to_string()),
// Names as AutoCAD may spell them.
K::Block => (1, v.as_str().unwrap().to_ascii_uppercase()),
K::TextStyle => (7, v.as_str().unwrap().to_ascii_uppercase()),
K::Linetype => (6, v.as_str().unwrap().to_string()),
K::Angle => (40, v.as_f64().unwrap().to_radians().to_string()),
};
vars.push_str(&format!(" 9\r\n${var}\r\n{code}\r\n{val}\r\n"));
}
let text = write_dxf(&d).replacen("HEADER\r\n", &format!("HEADER\r\n{vars}"), 1);
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(back.header.str("DIMDSEP", ""), ",");
assert!(back.header.get("DIMTXT").is_some() && back.header.get("DIMCLRD").is_some());
assert_eq!(back.dim_overrides(), serde_json::Map::new());
// A variable that really differs from the style is an override.
let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1);
assert_eq!(read_dxf(text.as_bytes()).unwrap().dim_overrides().get("decimals"), Some(&serde_json::json!(5)));
}
#[test]
fn mirrored_insert_extrusion_maps_to_negative_x_scale() {
// A block reference mirrored in AutoCAD is stored with extrusion (0,0,-1); its insertion point,
// scale and rotation are in that OCS, where x runs the other way: OCS (-10,5) is WCS (10,5).
let text = "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nB\n10\n0\n20\n0\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n0\nENDBLK\n0\nENDSEC\n\
0\nSECTION\n2\nENTITIES\n0\nINSERT\n8\n0\n2\nB\n10\n-10\n20\n5\n50\n90\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "m.dxf").unwrap();
let ins = first(&d, |k| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None });
assert!((ins.insert.x - 10.0).abs() < 1e-9 && (ins.insert.y - 5.0).abs() < 1e-9, "{:?}", ins.insert);
assert_eq!((ins.scale.x, ins.scale.y), (-1.0, 1.0));
assert!((ins.rotation + std::f64::consts::FRAC_PI_2).abs() < 1e-12, "{}", ins.rotation);
// In the OCS the line (0,0)-(2,0) turned by 90° runs from (-10,5) to (-10,7); mirrored into the WCS
// that is (10,5)-(10,7). The same follows from T(10,5)·R(-90°)·S(-1,1): (2,0) → (-2,0) → (0,2) → (10,7).
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, 10.0), (b.min.y, 5.0), (b.max.x, 10.0), (b.max.y, 7.0)] {
assert!((got - want).abs() < 1e-9, "extents {b:?}");
}
// Written back in the WCS (negative x scale, no extrusion), it reads back in the same place.
let b2 = roundtrip(&d).extents(&Space::Model);
assert!((b2.min - b.min).len() < 1e-9 && (b2.max - b.max).len() < 1e-9, "{b2:?} vs {b:?}");
}
#[test]
fn mirrored_ellipse_extrusion_reverses_parameters() {
// Extrusion (0,0,-1) flips the minor axis (extrusion × major): parameters 0..90° of the ellipse
// centred at the origin with major (2,0) and ratio 0.5 run from (2,0) to (0,-1), not (0,1).
let text = "0\nSECTION\n2\nENTITIES\n0\nELLIPSE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n40\n0.5\n41\n0\n42\n1.5707963267948966\n\
210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "e.dxf").unwrap();
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, 0.0), (b.min.y, -1.0), (b.max.x, 2.0), (b.max.y, 0.0)] {
assert!((got - want).abs() < 1e-6, "extents {b:?}");
}
}
#[test]
fn mirrored_2d_polyline_extrusion_flips_x_and_bulge() {
// R12-style 2D POLYLINE vertices are OCS like LWPOLYLINE: with extrusion (0,0,-1), OCS (1,0) is WCS
// (-1,0) and a counter-clockwise bulge turns clockwise.
let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n0\n210\n0\n220\n0\n230\n-1\n\
0\nVERTEX\n8\n0\n10\n1\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n3\n20\n0\n0\nSEQEND\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "p.dxf").unwrap();
let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
let got: Vec<(f64, f64, f64)> = p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect();
assert_eq!(got, vec![(-1.0, 0.0, -1.0), (-3.0, 0.0, 0.0)]);
}
#[test]
fn mirrored_hatch_extrusion_flips_boundary() {
// HATCH boundaries are OCS: the square (1,0)..(3,2) under extrusion (0,0,-1) lies at (-3,0)..(-1,2).
let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n-1\n2\nSOLID\n70\n1\n71\n0\n91\n1\n\
92\n2\n72\n0\n73\n1\n93\n4\n10\n1\n20\n0\n10\n3\n20\n0\n10\n3\n20\n2\n10\n1\n20\n2\n97\n0\n75\n0\n76\n1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "h.dxf").unwrap();
let b = d.extents(&Space::Model);
for (got, want) in [(b.min.x, -3.0), (b.min.y, 0.0), (b.max.x, -1.0), (b.max.y, 2.0)] {
assert!((got - want).abs() < 1e-9, "extents {b:?}");
}
}
/// The model extents of a one-loop solid HATCH whose edge boundary is `edges` (group 72 onwards).
fn hatch_edge_extents(edges: &str) -> Bounds2 {
let n = edges.matches("72\n").count();
let text = format!(
"0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n1\n2\nSOLID\n70\n1\n71\n0\n\
91\n1\n92\n1\n93\n{n}\n{edges}97\n0\n75\n0\n76\n1\n98\n0\n0\nENDSEC\n0\nEOF\n"
);
read(text.as_bytes(), "h.dxf").unwrap().extents(&Space::Model)
}
/// Issues #492, #493, #494: a full-circle arc edge, spline edges (knots come before the control
/// points) and clockwise ellipse edges all keep their curve.
#[test]
fn hatch_edges_keep_full_circles_splines_and_clockwise_ellipses() {
let near = |b: Bounds2, want: [f64; 4]| {
let got = [b.min.x, b.min.y, b.max.x, b.max.y];
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "extents {got:?}, want {want:?}");
};
// #492: one arc edge from 0 to 360.
near(hatch_edge_extents("72\n2\n10\n0\n20\n0\n40\n5\n50\n0\n51\n360\n73\n1\n"), [-5.0, -5.0, 5.0, 5.0]);
// #493: a cubic Bezier through (0,0)..(4,0) peaking at y = 3, closed by a line.
let spline = "72\n4\n94\n3\n73\n0\n74\n0\n95\n8\n96\n4\n40\n0\n40\n0\n40\n0\n40\n0\n40\n1\n40\n1\n40\n1\n40\n1\n\
10\n0\n20\n0\n10\n0\n20\n4\n10\n4\n20\n4\n10\n4\n20\n0\n97\n0\n";
near(hatch_edge_extents(&format!("{spline}72\n1\n10\n4\n20\n0\n11\n0\n21\n0\n")), [0.0, 0.0, 4.0, 3.0]);
// #494: the clockwise half-ellipse from 0 to 180 is the lower half, closed by its diameter.
let ellipse = |ccw| format!("72\n3\n10\n0\n20\n0\n11\n5\n21\n0\n40\n0.5\n50\n0\n51\n180\n73\n{ccw}\n72\n1\n10\n-5\n20\n0\n11\n5\n21\n0\n");
near(hatch_edge_extents(&ellipse(0)), [-5.0, -2.5, 5.0, 0.0]);
near(hatch_edge_extents(&ellipse(1)), [-5.0, 0.0, 5.0, 2.5]);
}
#[test]
fn mirrored_text_extrusion_keeps_its_footprint() {
// TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with
// mirrored glyphs). Drawn readable, it becomes right-justified at (10,5) with the same footprint.
let text = "0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n-10\n20\n5\n40\n1\n1\nAB\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "t.dxf").unwrap();
let t = first(&d, |k| if let EntityKind::Text(t) = k { Some(t.clone()) } else { None });
assert_eq!(t.halign, HAlign::Right);
assert!(t.rotation.abs() < 1e-12, "{}", t.rotation);
let a = t.align_pt.expect("right-justified text has an alignment point");
assert!((a.x - 10.0).abs() < 1e-9 && (a.y - 5.0).abs() < 1e-9, "{a:?}");
let list = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
assert!((list.bounds.max.x - 10.0).abs() < 1e-6 && list.bounds.min.x < 9.5, "drawn left of (10,5): {:?}", list.bounds);
}
#[test]
fn mirrored_mtext_extrusion_keeps_its_footprint() {
// MTEXT at (0,0) with direction (1,0), top-left attachment and extrusion (0,0,-1): the line height
// runs along extrusion × direction = (0,-1), so the text box lies above the insertion point
// (glyphs upside down). Drawn readable: turned round, top-right attachment, same box.
let text =
"0\nSECTION\n2\nENTITIES\n0\nMTEXT\n8\n0\n10\n0\n20\n0\n40\n1\n71\n1\n1\nAB\n11\n1\n21\n0\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n";
let d = read(text.as_bytes(), "mt.dxf").unwrap();
let t = first(&d, |k| if let EntityKind::MText(t) = k { Some(t.clone()) } else { None });
assert_eq!(t.attach, 3);
assert!((t.rotation - std::f64::consts::PI).abs() < 1e-12, "{}", t.rotation);
let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}");
}
// --- SVG import ---
/// One 20mm×10mm page whose user units are millimetres; the reader flips Y, so a point at
/// SVG y lands at `10 - y` in the drawing.
const SVG_PAGE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10">"#;
fn kinds(d: &Drawing) -> Vec<&'static str> {
d.model.iter().map(|e| e.kind.type_name()).collect()
}
fn gzip_of(data: &[u8]) -> Vec<u8> {
let mut crc: u32 = 0xffff_ffff;
for b in data {
crc ^= u32::from(*b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb8_8320u32 & 0u32.wrapping_sub(crc & 1));
}
}
let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(data, 6));
out.extend_from_slice(&(!crc).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out
}
#[test]
fn svg_shapes_become_entities_in_millimetres() {
let svg = format!(
"{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/><circle cx=\"10\" cy=\"5\" r=\"2\"/>\
<ellipse cx=\"10\" cy=\"5\" rx=\"4\" ry=\"2\"/><line x1=\"0\" y1=\"0\" x2=\"20\" y2=\"10\"/>\
<polyline points=\"0,0 5,0 5,5\"/><polygon points=\"0,0 2,0 1,1\"/></svg>"
);
let d = read(svg.as_bytes(), "shapes.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Circle", "Ellipse", "Line", "Polyline", "Polyline"]);
let r = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(r.closed);
let got: Vec<Vec2> = r.vertices.iter().map(|v| v.p).collect();
let want = [Vec2::new(1.0, 9.0), Vec2::new(5.0, 9.0), Vec2::new(5.0, 6.0), Vec2::new(1.0, 6.0)];
for (g, w) in got.iter().zip(want.iter()) {
assert!((*g - *w).len() < 1e-9, "{g:?} vs {w:?}");
}
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
assert!((c.radius - 2.0).abs() < 1e-9 && (c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
let e = first(&d, |k| if let EntityKind::Ellipse(e) = k { Some(e.clone()) } else { None });
let major = Vec2::new(e.major.x - e.center.x, e.major.y - e.center.y);
assert!((major.len() - 4.0).abs() < 1e-9 && (e.ratio - 0.5).abs() < 1e-9, "{e:?}");
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.a.y - 10.0).abs() < 1e-9 && (l.b.y).abs() < 1e-9, "{l:?}");
}
#[test]
fn svg_path_lines_curves_and_arcs() {
let svg = format!("{SVG_PAGE}<path d=\"M1 1 L5 1 C6 1 7 2 8 3\"/><path d=\"M0 0 A5 5 0 0 1 10 0 Z\"/></svg>");
let d = read(svg.as_bytes(), "paths.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline", "Spline", "Polyline"]);
let line_run = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
assert!(!line_run.closed && line_run.vertices.len() == 2, "{line_run:?}");
let spline = first(&d, |k| if let EntityKind::Spline(s) = k { Some(s.clone()) } else { None });
assert!(spline.is_valid(), "{spline:?}");
let arc = d
.model
.iter()
.find_map(|e| match &e.kind {
EntityKind::LwPolyline(p) if p.vertices.first().is_some_and(|v| v.bulge != 0.0) => Some(p.clone()),
_ => None,
})
.expect("an arc becomes a bulge vertex");
assert!(arc.closed && arc.vertices.len() == 2, "{arc:?}");
let bulge = arc.vertices[0].bulge;
assert!((bulge.abs() - 1.0).abs() < 1e-6, "semicircle bulge is ±1, got {bulge}");
}
#[test]
fn svg_group_transforms_apply() {
let svg = format!("{SVG_PAGE}<g transform=\"translate(5,2)\"><line x1=\"0\" y1=\"0\" x2=\"10\" y2=\"0\"/></g></svg>");
let d = read(svg.as_bytes(), "t.svg").unwrap();
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
assert!((l.a - Vec3::new(5.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
}
#[test]
fn svg_viewbox_scaling_and_mm_units() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 200 100"><circle cx="100" cy="50" r="50"/></svg>"#;
let d = read(svg.as_bytes(), "vb.svg").unwrap();
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
assert!((c.radius - 5.0).abs() < 1e-9, "{c:?}");
assert!((c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
}
#[test]
fn svg_inkscape_layers_are_kept() {
let svg = format!(
"{SVG_PAGE}<g inkscape:groupmode=\"layer\" inkscape:label=\"Cut\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></g>\
<g inkscape:groupmode=\"layer\" inkscape:label=\"Engrave\"><circle cx=\"5\" cy=\"5\" r=\"1\"/></g></svg>",
SVG_PAGE = SVG_PAGE.replace("<svg ", "<svg xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" ")
);
let d = read(svg.as_bytes(), "layers.svg").unwrap();
let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
assert!(layers.contains(&"Cut") && layers.contains(&"Engrave"), "{layers:?}");
let per_layer = |name: &str| d.model.iter().filter(|e| e.common.layer == name).count();
assert_eq!(per_layer("Cut"), 1);
assert_eq!(per_layer("Engrave"), 1);
}
#[test]
fn svg_hidden_and_defs_subtrees_are_skipped() {
let svg = format!(
"{SVG_PAGE}<defs><rect x=\"0\" y=\"0\" width=\"9\" height=\"9\"/></defs><text x=\"0\" y=\"0\">hi</text>\
<rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" display=\"none\"/><rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" style=\"fill:red;display:none\"/>\
<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>"
);
let d = read(svg.as_bytes(), "skip.svg").unwrap();
assert_eq!(kinds(&d), ["Polyline"]);
}
#[test]
fn svgz_gunzips_to_the_same_drawing() {
let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>");
let plain = read(svg.as_bytes(), "a.svg").unwrap();
let packed = read(&gzip_of(svg.as_bytes()), "a.svgz").unwrap();
assert_eq!(kinds(&plain), kinds(&packed));
assert_eq!(plain.model.iter().map(|e| &e.kind).collect::<Vec<_>>(), packed.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
}
#[test]
fn svg_read_dispatches_by_extension() {
let svg = format!("{SVG_PAGE}<circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>");
assert!(read(svg.as_bytes(), "a.svg").is_ok());
assert!(read(&gzip_of(svg.as_bytes()), "a.svgz").is_ok());
}
#[test]
fn svg_hostile_input_errors_without_panicking() {
let deep = format!("{SVG_PAGE}{}<line x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"/>{}</svg>", "<g>".repeat(200), "</g>".repeat(200));
for (bytes, name) in [
(b"not xml at all".to_vec(), "a.svg"),
(Vec::new(), "a.svg"),
(b"<svg><rect x=\"1e309\" width=\"NaN\" height=\"inf\"/></svg>".to_vec(), "a.svg"),
(deep.clone().into_bytes(), "a.svg"),
(b"<svg><path d=\"M0 0 X5 5\"/></svg>".to_vec(), "a.svg"),
(b"<svg><path d=\"M0 0 L\"/></svg>".to_vec(), "a.svg"),
(b"<svg><path d=\"M0 0 A1 1 0 5 1 2 2\"/></svg>".to_vec(), "a.svg"),
(b"<svg><polyline points=\"1 2 3\"/></svg>".to_vec(), "a.svg"),
(b"<svg><g transform=\"bogus(1)\"><rect width=\"1\" height=\"1\"/></g></svg>".to_vec(), "a.svg"),
(vec![0x1f, 0x8b, 8, 0, b'j', b'u', b'n', b'k', 0, 0, 0, 0, 0, 0, 0, 0], "a.svgz"),
(vec![0xff, 0xfe, 0x00, 0x01, 0x02, 0x03], "a.svg"),
] {
let _ = read(&bytes, name);
}
let deep = read(deep.as_bytes(), "a.svg");
assert!(deep.is_err(), "200 nested groups are refused, not walked");
}
#[test]
fn svg_no_geometry_yields_an_empty_drawing() {
let svg = format!("{SVG_PAGE}</svg>");
let d = read(svg.as_bytes(), "empty.svg").unwrap();
assert!(kinds(&d).is_empty());
}
/// An application data group inside a kept XRECORD — the AcDs thumbnail schema's
/// `{ATTRRECORD 341 h 2 name 280 1 291 1 ATTRRECORD}` — is written back as read. It used to be
/// collapsed into a handle list: the handle came out under group 291 (a bool) and the name and
/// flags were dropped, which strict DXF readers (ezdxf) reject outright.
#[test]
fn kept_xrecord_application_group_roundtrips_as_read() {
let dxf = "0\nSECTION\n2\nOBJECTS\n\
0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n3\nTD_SCHEMA\n350\n20\n\
0\nXRECORD\n5\n20\n330\nC\n100\nAcDbXrecord\n280\n1\n1\nAcDb_Thumbnail_Schema\n\
102\n{ATTRRECORD\n341\n21\n2\nAcDbDs::TreatedAsObjectData\n280\n1\n291\n1\n102\nATTRRECORD}\n\
0\nXRECORD\n5\n21\n330\n20\n100\nAcDbXrecord\n280\n1\n1\nTarget\n\
0\nENDSEC\n0\nEOF\n";
let d = read_dxf(dxf.as_bytes()).expect("read");
let out = write_dxf(&d);
let lines: Vec<&str> = out.lines().map(str::trim).collect();
let at = lines.iter().position(|l| *l == "{ATTRRECORD").expect("group kept");
// The group keeps its own shape: a reference under 341, then the name, flag and bool.
assert_eq!(lines[at + 1], "341", "reference group code: {:?}", &lines[at..at + 12]);
assert!(lines[at + 2] != "0" && !lines[at + 2].is_empty());
assert_eq!(&lines[at + 3..at + 5], &["2", "AcDbDs::TreatedAsObjectData"]);
assert_eq!(&lines[at + 5..at + 7], &["280", "1"]);
assert_eq!(&lines[at + 7..at + 9], &["291", "1"]);
assert_eq!(&lines[at + 9..at + 11], &["102", "ATTRRECORD}"]);
// And no handle is written under a bool code anywhere.
assert!(!out.contains("291\n2"), "no handle may be written under a bool code");
}