mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
1152 lines
49 KiB
Rust
1152 lines
49 KiB
Rust
//! DXF import: a fixture per entity, bulge and spline accuracy, blocks as symbols, layers,
|
||
//! colours, lineweights, layouts, the options, damaged and binary files, and drawings the export
|
||
//! wrote read back.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use kurbo::ParamCurve;
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, Document, Justify, Node, NodeKind, TextObject, Unit};
|
||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
|
||
|
||
use super::*;
|
||
|
||
/// A DXF file from `(code, value)` pairs written one per line.
|
||
fn file(pairs: &[(i32, &str)]) -> Vec<u8> {
|
||
let mut s = String::new();
|
||
for (c, v) in pairs {
|
||
s.push_str(&format!("{c:>3}\n{v}\n"));
|
||
}
|
||
s.push_str(" 0\nEOF\n");
|
||
s.into_bytes()
|
||
}
|
||
|
||
/// A drawing with `header` variables, `tables`, `blocks` and `entities` (each a run of pairs).
|
||
fn drawing(header: &[(i32, &str)], tables: &[(i32, &str)], blocks: &[(i32, &str)], entities: &[(i32, &str)]) -> Vec<u8> {
|
||
let mut p: Vec<(i32, &str)> = vec![(0, "SECTION"), (2, "HEADER")];
|
||
p.extend_from_slice(header);
|
||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "TABLES")]);
|
||
p.extend_from_slice(tables);
|
||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "BLOCKS")]);
|
||
p.extend_from_slice(blocks);
|
||
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")]);
|
||
p.extend_from_slice(entities);
|
||
p.push((0, "ENDSEC"));
|
||
file(&p)
|
||
}
|
||
|
||
fn entities(e: &[(i32, &str)]) -> Vec<u8> {
|
||
drawing(&[], &[], &[], e)
|
||
}
|
||
|
||
/// Points at 1:1, not centred: drawing (x, y) lands at document (x, artboard height − y).
|
||
fn points() -> ImportOptions {
|
||
ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }
|
||
}
|
||
|
||
fn open(bytes: &[u8], o: &ImportOptions) -> Imported {
|
||
import(bytes, o).unwrap()
|
||
}
|
||
|
||
/// Every leaf object, in paint order.
|
||
fn leaves(doc: &Document) -> Vec<Node> {
|
||
let mut out = vec![];
|
||
doc.walk(|n| {
|
||
if n.children().is_none() {
|
||
out.push(n.clone());
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// The artboard's height (document y = height − drawing y).
|
||
fn height(doc: &Document) -> f64 {
|
||
doc.artboards[0].rect.height()
|
||
}
|
||
|
||
fn path_points(n: &Node) -> Vec<Point> {
|
||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {:?}", n.kind) };
|
||
path.to_bezpath().segments().flat_map(|s| (0..=16).map(move |i| s.eval(f64::from(i) / 16.0))).collect()
|
||
}
|
||
|
||
fn bounds(n: &Node) -> Rect {
|
||
n.geometric_bounds().unwrap()
|
||
}
|
||
|
||
fn close(a: Rect, b: Rect, tol: f64) -> bool {
|
||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
|
||
}
|
||
|
||
fn rgba(p: Option<&Paint>) -> [u8; 4] {
|
||
match p {
|
||
Some(Paint::Solid { color, .. }) => color.to_rgba8(1.0),
|
||
other => panic!("not a solid paint: {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn stroke_rgb(n: &Node) -> [u8; 4] {
|
||
rgba(n.appearance.stroke().map(|s| &s.paint))
|
||
}
|
||
|
||
fn fill_rgb(n: &Node) -> [u8; 4] {
|
||
rgba(n.appearance.fill().map(|f| &f.paint))
|
||
}
|
||
|
||
fn text_of(n: &Node) -> &TextObject {
|
||
let NodeKind::Text(t) = &n.kind else { panic!("not text: {:?}", n.kind) };
|
||
t
|
||
}
|
||
|
||
const LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "10"), (20, "20"), (11, "110"), (21, "70")];
|
||
|
||
#[test]
|
||
fn a_line_comes_in_at_the_drawings_unit_and_origin() {
|
||
let doc = open(&entities(LINE), &points()).document;
|
||
let [line] = &leaves(&doc)[..] else { panic!() };
|
||
let board = doc.artboards[0].rect;
|
||
assert_eq!((board.width(), board.height()), (100.0, 50.0), "the artboard is the art's size");
|
||
// Not centred: the origin sits on the artboard's bottom-left corner.
|
||
let pts = path_points(line);
|
||
assert!(pts.first().unwrap().distance(Point::new(10.0, 50.0 - 20.0)) < 1e-9, "{pts:?}");
|
||
assert!(pts.last().unwrap().distance(Point::new(110.0, 50.0 - 70.0)) < 1e-9);
|
||
assert_eq!(stroke_rgb(line), [0, 0, 0, 255], "colour 7 is black on paper");
|
||
let w = line.appearance.stroke().unwrap().width;
|
||
assert!((w - 0.25 * 72.0 / 25.4).abs() < 1e-9, "the default lineweight is 0.25 mm: {w}");
|
||
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn units_choose_the_default_scale() {
|
||
// Millimetres at 1:1: 100 units are 100 mm.
|
||
let mm = drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], LINE);
|
||
let doc = open(&mm, &ImportOptions::default()).document;
|
||
assert!((doc.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
|
||
assert_eq!(doc.units, Unit::Millimeters);
|
||
// Inches; microns (1 mm = 1000 units); unitless imperial drawings read inches.
|
||
let inch = open(&drawing(&[(9, "$INSUNITS"), (70, "1")], &[], &[], LINE), &ImportOptions::default()).document;
|
||
assert!((inch.artboards[0].rect.width() - 7200.0).abs() < 1e-6);
|
||
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
|
||
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
|
||
assert!((um.scale - 1000.0).abs() < 1e-6);
|
||
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
|
||
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
|
||
for (code, name, pt) in [
|
||
("21", "US survey feet", survey_ft),
|
||
("22", "US survey inches", survey_ft / 12.0),
|
||
("23", "US survey yards", survey_ft * 3.0),
|
||
("24", "US survey miles", survey_ft * 5280.0),
|
||
] {
|
||
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
|
||
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
|
||
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
|
||
}
|
||
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
|
||
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
|
||
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
|
||
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
|
||
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
|
||
let o = ImportOptions { unit: Some(Unit::Centimeters), scale: Some(10.0), ..ImportOptions::default() };
|
||
let cm = open(&mm, &o).document;
|
||
assert!((cm.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
|
||
assert_eq!(cm.units, Unit::Centimeters);
|
||
}
|
||
|
||
#[test]
|
||
fn circles_arcs_and_ellipses() {
|
||
let e = [
|
||
(0, "CIRCLE"),
|
||
(10, "50"),
|
||
(20, "50"),
|
||
(40, "25"),
|
||
(0, "ARC"),
|
||
(10, "200"),
|
||
(20, "50"),
|
||
(40, "20"),
|
||
(50, "0"),
|
||
(51, "90"),
|
||
(0, "ELLIPSE"),
|
||
(10, "400"),
|
||
(20, "50"),
|
||
(11, "40"),
|
||
(21, "0"),
|
||
(40, "0.5"),
|
||
(41, "0"),
|
||
(42, "6.283185307179586"),
|
||
];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let h = height(&doc);
|
||
let flip = |r: Rect| Rect::new(r.x0, h - r.y1, r.x1, h - r.y0);
|
||
let [circle, arc, ellipse] = &leaves(&doc)[..] else { panic!() };
|
||
assert!(close(bounds(circle), flip(Rect::new(25.0, 25.0, 75.0, 75.0)), 1e-3), "{:?}", bounds(circle));
|
||
for p in path_points(circle) {
|
||
assert!((p.distance(Point::new(50.0, h - 50.0)) - 25.0).abs() < 1e-4);
|
||
}
|
||
// A quarter arc counter-clockwise from 0° to 90°.
|
||
assert!(close(bounds(arc), flip(Rect::new(200.0, 50.0, 220.0, 70.0)), 1e-3), "{:?}", bounds(arc));
|
||
assert!(close(bounds(ellipse), flip(Rect::new(360.0, 30.0, 440.0, 70.0)), 1e-3), "{:?}", bounds(ellipse));
|
||
let NodeKind::Path { path, .. } = &circle.kind else { panic!() };
|
||
assert!(path.is_closed());
|
||
}
|
||
|
||
#[test]
|
||
fn polyline_bulges_are_exact_arcs() {
|
||
// A slot: two straight sides and two half circles (bulge 1) of radius 10.
|
||
let e = [
|
||
(0, "LWPOLYLINE"),
|
||
(90, "4"),
|
||
(70, "1"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(10, "100"),
|
||
(20, "0"),
|
||
(42, "1"),
|
||
(10, "100"),
|
||
(20, "20"),
|
||
(10, "0"),
|
||
(20, "20"),
|
||
(42, "1"),
|
||
];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [slot] = &leaves(&doc)[..] else { panic!() };
|
||
let h = height(&doc);
|
||
// The arcs bulge outward, so the art is 120 wide.
|
||
assert!(close(bounds(slot), Rect::new(-10.0, h - 20.0, 110.0, h), 1e-4), "{:?}", bounds(slot));
|
||
for p in path_points(slot).into_iter().filter(|p| p.x > 100.0) {
|
||
let err = (p.distance(Point::new(100.0, h - 10.0)) - 10.0).abs();
|
||
assert!(err < 1e-4, "bulge error {err}");
|
||
}
|
||
// Old-style polylines read their vertices and bulges too.
|
||
let e = [
|
||
(0, "POLYLINE"),
|
||
(66, "1"),
|
||
(70, "0"),
|
||
(0, "VERTEX"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(42, "-1"),
|
||
(0, "VERTEX"),
|
||
(10, "20"),
|
||
(20, "0"),
|
||
(0, "SEQEND"),
|
||
(0, "LINE"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(11, "0"),
|
||
(21, "-30"),
|
||
];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [arc, _] = &leaves(&doc)[..] else { panic!() };
|
||
// Clockwise from (0, 0) to (20, 0): over the chord, 10 high.
|
||
let b = bounds(arc);
|
||
assert!((b.width() - 20.0).abs() < 1e-4 && (b.height() - 10.0).abs() < 1e-4, "{b:?}");
|
||
assert!((b.y1 - height(&doc)).abs() < 1e-4, "the arc's chord is at y 0: {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn nurbs_splines_stay_within_a_hundredth() {
|
||
// A rational quadratic circle of radius 100: nine weighted control points.
|
||
let w = std::f64::consts::FRAC_1_SQRT_2.to_string();
|
||
let mut e: Vec<(i32, String)> = vec![(0, "SPLINE".into()), (70, "12".into()), (71, "2".into()), (72, "12".into()), (73, "9".into())];
|
||
for k in ["0", "0", "0", "1", "1", "2", "2", "3", "3", "4", "4", "4"] {
|
||
e.push((40, k.into()));
|
||
}
|
||
for i in 0..9 {
|
||
e.push((41, if i % 2 == 0 { "1".into() } else { w.clone() }));
|
||
}
|
||
for (x, y) in [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0)] {
|
||
e.extend([(10, (200 + x * 100).to_string()), (20, (200 + y * 100).to_string()), (30, "0".into())]);
|
||
}
|
||
let e: Vec<(i32, &str)> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [circle] = &leaves(&doc)[..] else { panic!() };
|
||
let h = height(&doc);
|
||
let worst = path_points(circle).iter().map(|p| (p.distance(Point::new(200.0, h - 200.0)) - 100.0).abs()).fold(0.0, f64::max);
|
||
assert!(worst < 0.01, "NURBS error {worst}");
|
||
// A spline given by fit points alone passes through them.
|
||
let e = [(0, "SPLINE"), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [s] = &leaves(&doc)[..] else { panic!() };
|
||
let h = height(&doc);
|
||
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
|
||
}
|
||
|
||
#[test]
|
||
fn control_point_splines_honor_closed_flags_without_changing_their_curves() {
|
||
for returns in [false, true] {
|
||
for rational in [false, true] {
|
||
let mut original_curves = None;
|
||
for closed in [false, true] {
|
||
let flags = 8 | if rational { 4 } else { 0 } | i32::from(closed);
|
||
let mut e = vec![(0, "SPLINE".to_string()), (70, flags.to_string()), (71, "3".into()), (72, "11".into()), (73, "7".into())];
|
||
for k in [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2] {
|
||
e.push((40, k.to_string()));
|
||
}
|
||
let end = if returns { (20.0, 20.0) } else { (50.0, 25.0) };
|
||
for ((x, y), weight) in [(20.0, 20.0), (80.0, 0.0), (80.0, 80.0), (20.0, 80.0), (0.0, 80.0), (0.0, 20.0), end]
|
||
.into_iter()
|
||
.zip([1.0, 0.7, 0.7, 1.0, 0.9, 0.9, 1.0])
|
||
{
|
||
e.extend([(10, x.to_string()), (20, y.to_string()), (30, "0".into())]);
|
||
if rational {
|
||
e.push((41, weight.to_string()));
|
||
}
|
||
}
|
||
let e: Vec<_> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [n] = &leaves(&doc)[..] else { panic!("one spline") };
|
||
let path = n.path_data().unwrap();
|
||
assert_eq!(path.subpaths.len(), 1);
|
||
assert_eq!(path.subpaths[0].closed, closed, "flags {flags}, returns {returns}");
|
||
let curves: Vec<_> = path.to_bezpath().elements().iter().filter(|e| matches!(e, kurbo::PathEl::CurveTo(..))).copied().collect();
|
||
assert!(!curves.is_empty());
|
||
if let Some(original) = &original_curves {
|
||
assert_eq!(&curves, original, "closure preserves the rational/non-rational curves");
|
||
} else {
|
||
original_curves = Some(curves);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for flags in [8, 9] {
|
||
let flags_text = flags.to_string();
|
||
let e =
|
||
[(0, "SPLINE"), (70, flags_text.as_str()), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [n] = &leaves(&doc)[..] else { panic!("one fit-point spline") };
|
||
assert_eq!(n.path_data().unwrap().subpaths[0].closed, flags & 1 != 0, "fit-point fallback stays closed/open");
|
||
}
|
||
}
|
||
|
||
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
|
||
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
|
||
vec![
|
||
(0, "HATCH"),
|
||
(62, "1"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(30, "0"),
|
||
(2, if solid == "1" { "SOLID" } else { "ANSI31" }),
|
||
(70, solid),
|
||
(71, "0"),
|
||
(91, "2"),
|
||
(92, "3"),
|
||
(72, "0"),
|
||
(73, "1"),
|
||
(93, "4"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(10, "100"),
|
||
(20, "0"),
|
||
(10, "100"),
|
||
(20, "100"),
|
||
(10, "0"),
|
||
(20, "100"),
|
||
(97, "0"),
|
||
(92, "0"),
|
||
(93, "1"),
|
||
(72, "2"),
|
||
(10, "50"),
|
||
(20, "50"),
|
||
(40, "20"),
|
||
(50, "0"),
|
||
(51, "360"),
|
||
(73, "1"),
|
||
(97, "0"),
|
||
(75, "0"),
|
||
(76, "1"),
|
||
(98, "0"),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn solid_hatches_fill_and_pattern_hatches_outline() {
|
||
let r = open(&entities(&hatch("1")), &points());
|
||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||
let compound = r.document.layers[0].children().unwrap()[0].clone();
|
||
let NodeKind::Compound { children, rule } = &compound.kind else { panic!("{:?}", compound.kind) };
|
||
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd), "the hole is a second loop");
|
||
assert_eq!(fill_rgb(&compound), [255, 0, 0, 255]);
|
||
assert!(compound.appearance.stroke().is_none_or(|s| s.paint.is_none()));
|
||
assert_eq!(bounds(&compound).area(), 10_000.0);
|
||
let r = open(&entities(&hatch("0")), &points());
|
||
assert!(r.warnings.iter().any(|w| w.contains("pattern hatches")), "{:?}", r.warnings);
|
||
let outline = r.document.layers[0].children().unwrap()[0].clone();
|
||
assert_eq!(stroke_rgb(&outline), [255, 0, 0, 255]);
|
||
// Solids fill their corners in zigzag order.
|
||
let e = [(0, "SOLID"), (10, "0"), (20, "0"), (11, "10"), (21, "0"), (12, "0"), (22, "10"), (13, "10"), (23, "10")];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [square] = &leaves(&doc)[..] else { panic!() };
|
||
assert_eq!(bounds(square).area(), 100.0);
|
||
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn hatch_declaring_many_absent_source_handles_parses_quickly() {
|
||
let base = hatch("1");
|
||
let at = |code: i32, from: usize| from + base[from..].iter().position(|p| p.0 == code).unwrap();
|
||
let first = at(92, 0);
|
||
let end = at(97, first) + 1;
|
||
let mut boundary = base[first..end].to_vec();
|
||
*boundary.last_mut().unwrap() = (97, "10000000");
|
||
let n = 300;
|
||
let mut e: Vec<(i32, &'static str)> = base[..first].to_vec();
|
||
e[at(91, 0)] = (91, "300");
|
||
for _ in 0..n {
|
||
e.extend_from_slice(&boundary);
|
||
}
|
||
e.extend_from_slice(&base[end..]);
|
||
let start = std::time::Instant::now();
|
||
let r = open(&entities(&e), &points());
|
||
assert!(start.elapsed() < std::time::Duration::from_millis(400), "took {:?}", start.elapsed());
|
||
assert_eq!(r.document.layers[0].children().unwrap().len(), 1, "{:?}", r.warnings);
|
||
}
|
||
|
||
/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by
|
||
/// `source` (the loop's source-object count and handles), and a square hole.
|
||
fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> {
|
||
let mut e = vec![(0, "HATCH"), (62, "1"), (10, "0"), (20, "0"), (30, "0"), (2, "SOLID"), (70, "1"), (71, "1"), (91, "2")];
|
||
e.extend([(92, "1"), (93, "2"), (72, "1"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]);
|
||
e.extend([(72, "4"), (94, "3"), (73, "0"), (74, "0"), (95, "8"), (96, "4")]);
|
||
e.extend(["0", "0", "0", "0", "1", "1", "1", "1"].map(|k| (40, k)));
|
||
e.extend([(10, "100"), (20, "0"), (10, "100"), (20, "100"), (10, "0"), (20, "100"), (10, "0"), (20, "0")]);
|
||
e.extend_from_slice(fit);
|
||
e.extend_from_slice(source);
|
||
e.extend([(92, "2"), (72, "0"), (73, "1"), (93, "4"), (10, "40"), (20, "20"), (10, "60"), (20, "20")]);
|
||
e.extend([(10, "60"), (20, "40"), (10, "40"), (20, "40"), (97, "0"), (75, "0"), (76, "1"), (98, "0")]);
|
||
e
|
||
}
|
||
|
||
#[test]
|
||
fn hatch_spline_edges_with_or_without_fit_data() {
|
||
// Before DXF 2010 a spline edge has no fit data, so a 97 right after it is the loop's own
|
||
// count of source objects; from 2010 on it has a fit count, here 0, before that.
|
||
let source = [(97, "1"), (330, "1F")];
|
||
for fit in [&[][..], &[(97, "0")][..]] {
|
||
let r = open(&entities(&spline_edged_hatch(fit, &source)), &points());
|
||
let [hatch] = &r.document.layers[0].children().unwrap()[..] else { panic!("fit data {fit:?}: the hatch is lost") };
|
||
let NodeKind::Compound { children, .. } = &hatch.kind else { panic!("{:?}", hatch.kind) };
|
||
assert_eq!(children.len(), 2, "the outline and the hole, fit data {fit:?}");
|
||
// The spline edge peaks three quarters of the way up its control polygon.
|
||
let b = bounds(hatch);
|
||
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 75.0).abs() < 1e-6, "{b:?}");
|
||
}
|
||
// Fit points after the count are still read (and ignored when there are control points).
|
||
let fit = [(97, "2"), (11, "100"), (21, "0"), (11, "0"), (21, "0")];
|
||
let r = open(&entities(&spline_edged_hatch(&fit, &source)), &points());
|
||
assert_eq!(r.document.layers[0].children().unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn text_and_multiline_text() {
|
||
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")];
|
||
let e = [
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "10"),
|
||
(40, "7"),
|
||
(1, "Room %%c20"),
|
||
(50, "90"),
|
||
(7, "Notes"),
|
||
(0, "MTEXT"),
|
||
(10, "100"),
|
||
(20, "100"),
|
||
(40, "3.5"),
|
||
(71, "2"),
|
||
(3, "{\\fArial|b0;First}"),
|
||
(1, "\\PSecond"),
|
||
];
|
||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||
let [single, multi] = &leaves(&doc)[..] else { panic!() };
|
||
let h = height(&doc);
|
||
let t = text_of(single);
|
||
assert_eq!(t.plain_text(), "Room ⌀20");
|
||
let st = t.first_style();
|
||
assert!((st.size - 10.0).abs() < 1e-9, "a 7-unit cap height is 10 pt type: {}", st.size);
|
||
assert_eq!(st.font_family, "DejaVuSans");
|
||
// Turned a quarter counter-clockwise, its baseline starting at the insertion point.
|
||
let origin = t.xf * Point::ZERO;
|
||
assert!(origin.distance(Point::new(10.0, h - 10.0)) < 1e-9, "{origin:?}");
|
||
let along = t.xf * Point::new(1.0, 0.0) - origin;
|
||
assert!(along.x.abs() < 1e-9 && (along.y + 1.0).abs() < 1e-9, "{along:?}");
|
||
let m = text_of(multi);
|
||
assert_eq!(m.plain_text(), "First\nSecond");
|
||
assert_eq!(m.para.justify, Justify::Center, "attached top centre");
|
||
assert_eq!(m.first_style().font_family, "Arial");
|
||
// Top attachment: the first baseline one cap height below the insertion point.
|
||
let o = m.xf * Point::ZERO;
|
||
assert!(o.distance(Point::new(100.0, h - 100.0 + 3.5)) < 1e-9, "{o:?}");
|
||
let leading = m.first_style().leading.unwrap();
|
||
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
|
||
}
|
||
|
||
/// Where the first line's baseline starts and ends, in document coordinates.
|
||
fn baseline_ends(t: &TextObject) -> (Point, Point) {
|
||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||
let l = &layout.lines[0];
|
||
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
|
||
}
|
||
|
||
#[test]
|
||
fn fit_and_aligned_text_span_their_two_points() {
|
||
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
|
||
// text left-justified for its natural size.
|
||
let e = [
|
||
(0, "LINE"),
|
||
(62, "1"),
|
||
(10, "10"),
|
||
(20, "0"),
|
||
(11, "10"),
|
||
(21, "60"),
|
||
(0, "LINE"),
|
||
(62, "1"),
|
||
(10, "90"),
|
||
(20, "0"),
|
||
(11, "90"),
|
||
(21, "60"),
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "40"),
|
||
(40, "5"),
|
||
(1, "FIT BETWEEN"),
|
||
(72, "5"),
|
||
(11, "90"),
|
||
(21, "40"),
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "10"),
|
||
(40, "5"),
|
||
(41, "0.8"),
|
||
(1, "ALIGNED"),
|
||
(72, "3"),
|
||
(11, "90"),
|
||
(21, "10"),
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "25"),
|
||
(40, "5"),
|
||
(1, "FIT BETWEEN"),
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "50"),
|
||
(40, "5"),
|
||
(41, "0.8"),
|
||
(1, "ALIGNED"),
|
||
];
|
||
let o = ImportOptions { center: false, ..ImportOptions::default() };
|
||
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
|
||
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
|
||
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
|
||
for n in [fit, aligned] {
|
||
let (a, b) = baseline_ends(text_of(n));
|
||
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
|
||
assert!((a.y - b.y).abs() < 1e-9, "level");
|
||
}
|
||
// Fit keeps its height and stretches across.
|
||
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
|
||
assert_eq!(st.size, natural.size);
|
||
assert!(st.h_scale > 100.0, "{}", st.h_scale);
|
||
// Aligned grows as a whole, its width factor kept.
|
||
let (a, b) = baseline_ends(text_of(aligned_natural));
|
||
let k = (x90 - x10) / (b.x - a.x);
|
||
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
|
||
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
|
||
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
|
||
for h in ["3", "5"] {
|
||
let e = [
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "10"),
|
||
(40, "7"),
|
||
(1, "Gap"),
|
||
(72, h),
|
||
(0, "TEXT"),
|
||
(10, "10"),
|
||
(20, "30"),
|
||
(40, "7"),
|
||
(1, "Gap"),
|
||
(72, h),
|
||
(11, "10"),
|
||
(21, "30"),
|
||
];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let top = height(&doc);
|
||
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
|
||
let t = text_of(n);
|
||
let st = t.first_style();
|
||
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
|
||
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fit_and_aligned_text_scale_within_limits() {
|
||
// A span a billion units long and one a millionth of a unit long.
|
||
let e = [
|
||
(0, "TEXT"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(40, "7"),
|
||
(1, "X"),
|
||
(72, "5"),
|
||
(11, "1e9"),
|
||
(21, "0"),
|
||
(0, "TEXT"),
|
||
(10, "0"),
|
||
(20, "10"),
|
||
(40, "7"),
|
||
(1, "X"),
|
||
(72, "3"),
|
||
(11, "1e-6"),
|
||
(21, "10"),
|
||
];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
|
||
let st = text_of(fit).first_style();
|
||
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
|
||
let st = text_of(aligned).first_style();
|
||
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
|
||
}
|
||
|
||
#[test]
|
||
fn fit_attributes_span_their_two_points() {
|
||
let inserts = [
|
||
(0, "INSERT"),
|
||
(66, "1"),
|
||
(2, "Door"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(0, "ATTRIB"),
|
||
(10, "10"),
|
||
(20, "20"),
|
||
(40, "7"),
|
||
(1, "D-01"),
|
||
(2, "TAG"),
|
||
(70, "0"),
|
||
(72, "5"),
|
||
(74, "0"),
|
||
(11, "90"),
|
||
(21, "20"),
|
||
(0, "SEQEND"),
|
||
];
|
||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
|
||
let (a, b) = baseline_ends(text_of(&tag));
|
||
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
|
||
assert_eq!(text_of(&tag).first_style().size, 10.0);
|
||
}
|
||
|
||
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
|
||
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
|
||
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
|
||
blocks.extend_from_slice(block_art);
|
||
blocks.push((0, "ENDBLK"));
|
||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Doors"), (70, "0"), (62, "5"), (6, "CONTINUOUS"), (0, "ENDTAB")];
|
||
drawing(&[], &tables, &blocks, inserts)
|
||
}
|
||
|
||
/// A red line 10 units along x from the base point, on layer 0.
|
||
const DOOR_LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "1"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||
|
||
#[test]
|
||
fn inserts_become_symbol_instances_with_their_transform() {
|
||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
|
||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||
assert_eq!(doc.symbols.len(), 1);
|
||
assert_eq!(doc.symbols[0].name, "Door");
|
||
let [inst] = &leaves(&doc)[..] else { panic!() };
|
||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { panic!("{:?}", inst.kind) };
|
||
assert_eq!(symbol, "Door");
|
||
assert_eq!(doc.layers[0].name.as_deref(), Some("Doors"));
|
||
// Scaled 2 and turned a quarter: from the insertion point 20 units up.
|
||
let art = &doc.symbols[0].art;
|
||
let pts: Vec<Point> = path_points(art).iter().map(|p| *xf * *p).collect();
|
||
let h = height(&doc);
|
||
let (a, b) = (pts.first().unwrap(), pts.last().unwrap());
|
||
assert!(a.distance(Point::new(100.0, h - 50.0)) < 1e-9, "{a:?}");
|
||
assert!(b.distance(Point::new(100.0, h - 70.0)) < 1e-9, "{b:?}");
|
||
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
|
||
}
|
||
|
||
#[test]
|
||
fn an_insert_is_measured_clicked_and_marqueed_by_its_blocks_art() {
|
||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
|
||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||
let [inst] = &leaves(&doc)[..] else { panic!() };
|
||
let h = height(&doc);
|
||
// The line, 20 units up from the insertion point: a box with no width at x 100. The 20 × 20
|
||
// square placed by the insert's transform would be a 60 × 40 box from x 70 to 130.
|
||
let b = doc.bounds_of(&[inst.id], false).unwrap();
|
||
assert!(close(b, Rect::new(100.0, h - 70.0, 100.0, h - 50.0), 1e-9), "{b:?}");
|
||
// A click 25 pt right of the line, inside that square's box, selects nothing; one on the line
|
||
// selects the insert.
|
||
let hit = |x: f64| vectorcraft_doc::hit::hit_test(&doc, Point::new(x, h - 60.0), vectorcraft_doc::hit::HitOptions::default()).map(|h| h.leaf);
|
||
assert_eq!(hit(125.0), None);
|
||
assert_eq!(hit(100.0), Some(inst.id));
|
||
// A marquee beside the line leaves it out; one across it takes it.
|
||
let marquee = |r: Rect| vectorcraft_doc::hit::marquee(&doc, r, None, false);
|
||
assert!(marquee(Rect::new(115.0, h - 65.0, 125.0, h - 55.0)).is_empty());
|
||
assert_eq!(marquee(Rect::new(90.0, h - 62.0, 110.0, h - 58.0)), vec![inst.id]);
|
||
}
|
||
|
||
#[test]
|
||
fn by_block_colours_make_a_symbol_per_look() {
|
||
// The block's line takes the insert's colour: red and green make two symbols; a third red
|
||
// insert reuses the first.
|
||
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||
let inserts = [
|
||
(0, "INSERT"),
|
||
(2, "Door"),
|
||
(62, "1"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(0, "INSERT"),
|
||
(2, "Door"),
|
||
(62, "3"),
|
||
(10, "50"),
|
||
(20, "0"),
|
||
(0, "INSERT"),
|
||
(2, "Door"),
|
||
(62, "1"),
|
||
(10, "100"),
|
||
(20, "0"),
|
||
];
|
||
let doc = open(&with_door(by_block, &inserts), &points()).document;
|
||
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
|
||
assert_eq!(names, ["Door", "Door 2"]);
|
||
assert_eq!(stroke_rgb(&doc.symbols[0].art), [255, 0, 0, 255]);
|
||
assert_eq!(stroke_rgb(&doc.symbols[1].art), [0, 255, 0, 255]);
|
||
assert_eq!(leaves(&doc).len(), 3);
|
||
// Layer 0 art takes the insert's layer's colour.
|
||
let on_zero: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "0"), (20, "0"), (0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "40"), (20, "0")];
|
||
let doc = open(&with_door(on_zero, &inserts), &points()).document;
|
||
assert_eq!(doc.symbols.len(), 1);
|
||
assert_eq!(stroke_rgb(&doc.symbols[0].art), [0, 0, 255, 255], "layer Doors is blue");
|
||
// Art with its own colour shares one symbol, whatever the inserts' colours.
|
||
let inserts = [(0, "INSERT"), (2, "Door"), (62, "1"), (10, "0"), (20, "0"), (0, "INSERT"), (2, "Door"), (62, "3"), (10, "40"), (20, "0")];
|
||
let doc = open(&with_door(&[(0, "LINE"), (8, "Doors"), (62, "4"), (10, "5"), (20, "5"), (11, "15"), (21, "5")], &inserts), &points()).document;
|
||
assert_eq!(doc.symbols.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn many_by_block_looks_import_in_linear_time() {
|
||
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
|
||
let n = 4000;
|
||
let mut inserts: Vec<(i32, &'static str)> = vec![];
|
||
for i in 0..n {
|
||
let colour: &'static str = Box::leak((i + 1).to_string().into_boxed_str());
|
||
inserts.extend([(0, "INSERT"), (2, "Door"), (420, colour), (10, "0"), (20, "0")]);
|
||
}
|
||
let text = with_door(by_block, &inserts);
|
||
let start = std::time::Instant::now();
|
||
let doc = open(&text, &points()).document;
|
||
let took = start.elapsed();
|
||
let mut names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
|
||
assert_eq!(names.len(), n);
|
||
names.sort_unstable();
|
||
names.dedup();
|
||
assert_eq!(names.len(), n, "names stay unique");
|
||
assert!(took < std::time::Duration::from_secs(4), "import took {took:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn array_inserts_attributes_and_dimensions() {
|
||
let inserts = [
|
||
(0, "INSERT"),
|
||
(66, "1"),
|
||
(2, "Door"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(70, "3"),
|
||
(71, "2"),
|
||
(44, "20"),
|
||
(45, "30"),
|
||
(0, "ATTRIB"),
|
||
(10, "0"),
|
||
(20, "-10"),
|
||
(40, "2"),
|
||
(1, "D-01"),
|
||
(2, "TAG"),
|
||
(70, "0"),
|
||
(0, "ATTRIB"),
|
||
(10, "0"),
|
||
(20, "-20"),
|
||
(40, "2"),
|
||
(1, "hidden"),
|
||
(2, "NOTE"),
|
||
(70, "1"),
|
||
(0, "SEQEND"),
|
||
];
|
||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||
let all = leaves(&doc);
|
||
let instances: Vec<Point> =
|
||
all.iter().filter_map(|n| if let NodeKind::SymbolInstance { xf, .. } = &n.kind { Some(*xf * Point::ZERO) } else { None }).collect();
|
||
assert_eq!(instances.len(), 6, "3 columns × 2 rows");
|
||
let xs: std::collections::BTreeSet<i64> = instances.iter().map(|p| p.x.round() as i64).collect();
|
||
assert_eq!(xs.len(), 3, "columns 20 apart: {instances:?}");
|
||
let texts: Vec<String> = all.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
|
||
assert_eq!(texts, ["D-01"], "invisible attributes stay out");
|
||
// A dimension draws its anonymous block as art, not a symbol.
|
||
let blocks = [(0, "BLOCK"), (2, "*D1"), (70, "1"), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "50"), (21, "0"), (0, "ENDBLK")];
|
||
let dims = [(0, "DIMENSION"), (2, "*D1"), (10, "0"), (20, "0")];
|
||
let doc = open(&drawing(&[], &[], &blocks, &dims), &points()).document;
|
||
assert!(doc.symbols.is_empty());
|
||
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
|
||
}
|
||
|
||
#[test]
|
||
fn nested_array_inserts_of_nothing_stop_at_the_work_budget() {
|
||
let block = |name: &'static str, inner: Option<&'static str>| {
|
||
let mut b = vec![(0, "BLOCK"), (2, name), (70, "1"), (10, "0"), (20, "0")];
|
||
if let Some(i) = inner {
|
||
b.extend([(0, "INSERT"), (2, i), (10, "0"), (20, "0"), (70, "300"), (44, "1")]);
|
||
}
|
||
b.push((0, "ENDBLK"));
|
||
b
|
||
};
|
||
let mut blocks = block("*A", Some("*B"));
|
||
blocks.extend(block("*B", Some("*C")));
|
||
blocks.extend(block("*C", None));
|
||
let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")];
|
||
let r = open(&drawing(&[], &[], &blocks, &top), &points());
|
||
assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn an_array_of_a_large_anonymous_block_stops_at_the_path_element_budget() {
|
||
// A 2,000-vertex polyline in *U1, placed in a 100 × 10 array: 2,000,000 path elements when
|
||
// every cell is copied, twice the budget tests import with.
|
||
let mut owned: Vec<(i32, String)> = vec![(0, "BLOCK".into()), (2, "*U1".into()), (70, "1".into()), (10, "0".into()), (20, "0".into())];
|
||
owned.extend([(0, "LWPOLYLINE".into()), (90, "2000".into()), (70, "0".into())]);
|
||
for i in 0..2000 {
|
||
owned.extend([(10, i.to_string()), (20, (i % 2).to_string())]);
|
||
}
|
||
owned.push((0, "ENDBLK".into()));
|
||
let blocks: Vec<(i32, &str)> = owned.iter().map(|(c, v)| (*c, v.as_str())).collect();
|
||
let inserts = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "100"), (71, "10"), (44, "2000"), (45, "2")];
|
||
let r = open(&drawing(&[], &[], &blocks, &inserts), &points());
|
||
let mut elements = 0;
|
||
r.document.walk(|n| {
|
||
if let NodeKind::Path { path, .. } = &n.kind {
|
||
elements += path.to_bezpath().elements().len();
|
||
}
|
||
});
|
||
assert!(elements > 0);
|
||
assert!(elements <= 1_000_000, "{elements} path elements");
|
||
assert!(r.warnings.iter().any(|w| w.contains("path points")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn blocks_inside_themselves_are_left_out_without_hanging() {
|
||
let blocks = [
|
||
(0, "BLOCK"),
|
||
(2, "Loop"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(0, "LINE"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(11, "10"),
|
||
(21, "0"),
|
||
(0, "INSERT"),
|
||
(2, "Loop"),
|
||
(10, "20"),
|
||
(20, "0"),
|
||
(0, "ENDBLK"),
|
||
];
|
||
let r = open(&drawing(&[], &[], &blocks, &[(0, "INSERT"), (2, "Loop"), (10, "0"), (20, "0")]), &points());
|
||
assert!(r.warnings.iter().any(|w| w.contains("inside themselves")), "{:?}", r.warnings);
|
||
assert_eq!(r.document.symbols.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn layers_keep_their_state_colour_lineweight_and_linetype() {
|
||
let tables = [
|
||
(0, "TABLE"),
|
||
(2, "LTYPE"),
|
||
(0, "LTYPE"),
|
||
(2, "DASHED"),
|
||
(73, "2"),
|
||
(40, "15"),
|
||
(49, "10"),
|
||
(49, "-5"),
|
||
(0, "ENDTAB"),
|
||
(0, "TABLE"),
|
||
(2, "LAYER"),
|
||
(0, "LAYER"),
|
||
(2, "Hidden"),
|
||
(70, "0"),
|
||
(62, "-3"),
|
||
(6, "DASHED"),
|
||
(370, "50"),
|
||
(0, "LAYER"),
|
||
(2, "Frozen"),
|
||
(70, "1"),
|
||
(62, "2"),
|
||
(0, "LAYER"),
|
||
(2, "Locked"),
|
||
(70, "4"),
|
||
(62, "1"),
|
||
(420, "16744448"),
|
||
(290, "0"),
|
||
(0, "ENDTAB"),
|
||
];
|
||
let line = |layer: &'static str| [(0, "LINE"), (8, layer), (10, "0"), (20, "0"), (11, "100"), (21, "0")];
|
||
let e: Vec<(i32, &str)> = [line("Hidden"), line("Frozen"), line("Locked"), line("Unlisted")].concat();
|
||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||
let names: Vec<&str> = doc.layers.iter().filter_map(|l| l.name.as_deref()).collect();
|
||
assert_eq!(names, ["Hidden", "Frozen", "Locked", "Unlisted"]);
|
||
let [hidden, frozen, locked, plain] = &doc.layers[..] else { panic!() };
|
||
assert!(!hidden.visible && !frozen.visible && locked.visible && plain.visible);
|
||
assert!(locked.locked && !hidden.locked);
|
||
assert!(matches!(locked.kind, NodeKind::Layer { printable: false, .. }));
|
||
let first = |l: &Arc<Node>| l.children().unwrap()[0].clone();
|
||
// Colour 3 (green) and a 0.5 mm dashed line from the layer.
|
||
let h = first(hidden);
|
||
assert_eq!(stroke_rgb(&h), [0, 255, 0, 255]);
|
||
let st = h.appearance.stroke().unwrap();
|
||
assert!((st.width - 0.5 * 72.0 / 25.4).abs() < 1e-9);
|
||
assert_eq!(st.dash.as_ref().unwrap().pattern, [10.0, 5.0]);
|
||
assert_eq!(stroke_rgb(&first(frozen)), [255, 255, 0, 255]);
|
||
assert_eq!(stroke_rgb(&first(locked)), [255, 128, 0, 255], "true colour wins over the index");
|
||
// Merge Layers: one layer.
|
||
let merged = open(&drawing(&[], &tables, &[], &e), &ImportOptions { merge_layers: true, ..points() }).document;
|
||
assert_eq!(merged.layers.len(), 1);
|
||
assert_eq!(merged.layers[0].children().unwrap().len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn entity_colours_lineweights_and_transparency() {
|
||
let e = [
|
||
(0, "LINE"),
|
||
(62, "30"),
|
||
(370, "100"),
|
||
(440, "33554559"),
|
||
(10, "0"),
|
||
(20, "0"),
|
||
(11, "10"),
|
||
(21, "0"),
|
||
(0, "LINE"),
|
||
(62, "5"),
|
||
(420, "65280"),
|
||
(370, "0"),
|
||
(10, "0"),
|
||
(20, "10"),
|
||
(11, "10"),
|
||
(21, "10"),
|
||
];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let [a, b] = &leaves(&doc)[..] else { panic!() };
|
||
assert_eq!(stroke_rgb(a), [255, 127, 0, 255], "index 30 is orange");
|
||
assert!((a.appearance.stroke().unwrap().width - 72.0 / 25.4).abs() < 1e-9, "1 mm");
|
||
assert!((a.opacity - 127.0 / 255.0).abs() < 1e-6);
|
||
assert_eq!(stroke_rgb(b), [0, 255, 0, 255]);
|
||
assert_eq!(b.appearance.stroke().unwrap().width, 0.25, "lineweight 0 is the thinnest line");
|
||
// Scale Lineweights: at 1 pt = 10 units (a tenth of 1:1 in millimetres) they shrink too.
|
||
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(10.0), scale_lineweights: true, ..ImportOptions::default() };
|
||
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
|
||
let w = leaves(&doc)[0].appearance.stroke().unwrap().width;
|
||
let k = 0.1 / (72.0 / 25.4);
|
||
assert!((w - 72.0 / 25.4 * k).abs() < 1e-9, "{w}");
|
||
}
|
||
|
||
#[test]
|
||
fn fit_and_centre_place_the_art_on_the_artboard() {
|
||
let doc = open(&entities(LINE), &ImportOptions { fit: true, ..ImportOptions::default() }).document;
|
||
let board = doc.artboards[0].rect;
|
||
assert_eq!((board.width(), board.height()), (792.0, 612.0), "landscape letter for wide art");
|
||
let b = bounds(&leaves(&doc)[0]);
|
||
assert!((b.width() - 792.0).abs() < 1e-6 && (b.center().y - 306.0).abs() < 1e-6, "{b:?}");
|
||
let o = ImportOptions { fit: true, fit_to: (200.0, 100.0), center: false, ..ImportOptions::default() };
|
||
let b = bounds(&leaves(&open(&entities(LINE), &o).document)[0]);
|
||
assert!(close(b, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-6), "bottom-left: {b:?}");
|
||
// Centred at a ratio, the artboard is the art's bounds.
|
||
let doc = open(&entities(LINE), &ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), ..ImportOptions::default() }).document;
|
||
assert!(close(bounds(&leaves(&doc)[0]), doc.artboards[0].rect, 1e-9));
|
||
// Art too large for the canvas at the scale asked for says so.
|
||
let o = ImportOptions { unit: Some(Unit::Meters), scale: Some(0.001), ..ImportOptions::default() };
|
||
let e = import(&entities(LINE), &o).unwrap_err();
|
||
assert!(e.contains("canvas"), "{e}");
|
||
let e = import(&entities(LINE), &ImportOptions { scale: Some(-1.0), ..ImportOptions::default() }).unwrap_err();
|
||
assert!(e.contains("scale"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn paper_layouts() {
|
||
let mut e = LINE.to_vec();
|
||
e.extend([(0, "CIRCLE"), (67, "1"), (10, "0"), (20, "0"), (40, "5")]);
|
||
let bytes = entities(&e);
|
||
assert_eq!(info(&bytes).unwrap().layouts, ["Model", "Layout1"]);
|
||
let model = leaves(&open(&bytes, &points()).document);
|
||
assert_eq!(model.len(), 1);
|
||
assert_eq!(bounds(&model[0]).width(), 100.0, "model space holds the line");
|
||
let paper = leaves(&open(&bytes, &ImportOptions { layout: Some("layout1".into()), ..points() }).document);
|
||
assert_eq!(bounds(&paper[0]).width(), 10.0, "the paper layout holds the circle");
|
||
let e = import(&bytes, &ImportOptions { layout: Some("Sheet".into()), ..points() }).unwrap_err();
|
||
assert!(e.contains("Model, Layout1"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn binary_dwg_damaged_and_foreign_files() {
|
||
let mut binary = BINARY_SENTINEL.to_vec();
|
||
binary.extend([0u8; 40]);
|
||
assert!(is_dxf(&binary));
|
||
let e = import(&binary, &ImportOptions::default()).unwrap_err();
|
||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
|
||
assert!(e.contains("DWG"), "{e}");
|
||
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
|
||
assert!(import(junk, &ImportOptions::default()).is_err(), "{junk:?}");
|
||
assert!(!is_dxf(junk), "{junk:?}");
|
||
}
|
||
assert!(is_dxf(b"999\nwritten by hand\n 0\nSECTION\n 2\nHEADER\n"));
|
||
// A file cut off in the middle keeps what came before.
|
||
let mut cut = entities(LINE);
|
||
cut.truncate(cut.len() - 30);
|
||
cut.extend(b"\nnot a code\n");
|
||
let r = open(&cut, &points());
|
||
assert_eq!(leaves(&r.document).len(), 1);
|
||
assert!(r.warnings.iter().any(|w| w.contains("damaged")), "{:?}", r.warnings);
|
||
// Hostile numbers are dropped; unknown entities are listed.
|
||
let e =
|
||
[(0, "CIRCLE"), (10, "1e999"), (20, "NaN"), (40, "1e300"), (0, "XLINE"), (10, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "1")];
|
||
let r = open(&entities(&e), &points());
|
||
assert!(r.warnings.iter().any(|w| w.contains("1 XLINE")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn drawings_the_export_writes_read_back() {
|
||
let mut doc = Document::new(200.0, 100.0);
|
||
let layer = doc.layers[0].id;
|
||
let id = doc.alloc_id();
|
||
let rect = shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0));
|
||
let blue = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 255)), 2.0);
|
||
doc.insert(Some(layer), 0, Node::path(id, rect, blue)).unwrap();
|
||
let id = doc.alloc_id();
|
||
let circle = shapes::ellipse(Rect::new(140.0, 20.0, 180.0, 60.0));
|
||
doc.insert(Some(layer), 1, Node::path(id, circle, Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0))).unwrap();
|
||
for version in [crate::DxfVersion::R12, crate::DxfVersion::R2000, crate::DxfVersion::R2018] {
|
||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||
let out = crate::export(&doc, &opts).unwrap();
|
||
let back = open(&out.bytes, &points());
|
||
let art = leaves(&back.document);
|
||
// Same place on the artboard (the export's origin is the artboard's bottom-left).
|
||
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
|
||
let all = art.iter().filter_map(|n| n.geometric_bounds()).map(|b| shift.transform_rect_bbox(b)).reduce(|a, b| a.union(b)).unwrap();
|
||
assert!(close(all, Rect::new(20.0, 20.0, 180.0, 70.0), 0.1), "{version:?}: {all:?}");
|
||
let stroked = art.iter().find(|n| n.appearance.stroke().is_some_and(|s| !s.paint.is_none())).unwrap();
|
||
assert_eq!(stroke_rgb(stroked), [0, 0, 255, 255], "{version:?}");
|
||
if version >= crate::DxfVersion::R2000 {
|
||
assert!(art.iter().any(|n| n.appearance.fill().is_some_and(|f| !f.paint.is_none())), "{version:?}: the fill comes back as a hatch");
|
||
let w = stroked.appearance.stroke().unwrap().width;
|
||
assert!((w - 2.0).abs() < 0.2, "{version:?}: lineweight {w}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn closed_control_point_splines_stay_closed_through_export_and_import() {
|
||
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
|
||
let cases = [(true, true), (true, false), (false, true), (false, false)];
|
||
let mut doc = Document::new(200.0, 100.0);
|
||
let layer = doc.layers[0].id;
|
||
for (i, &(closed, returns)) in cases.iter().enumerate() {
|
||
let mut b = kurbo::BezPath::new();
|
||
b.move_to((20.0, 20.0));
|
||
b.curve_to((80.0, 0.0), (80.0, 80.0), (20.0, 80.0));
|
||
if returns {
|
||
b.curve_to((0.0, 80.0), (0.0, 20.0), (20.0, 20.0));
|
||
}
|
||
if closed {
|
||
b.close_path();
|
||
}
|
||
let id = doc.alloc_id();
|
||
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
|
||
doc.insert(Some(layer), i, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
|
||
}
|
||
for version in crate::DxfVersion::ALL.into_iter().filter(|v| *v >= crate::DxfVersion::R13) {
|
||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||
let out = crate::export(&doc, &opts).unwrap();
|
||
let text = std::str::from_utf8(&out.bytes).unwrap();
|
||
assert_eq!(text.matches("\nSPLINE\n").count(), cases.len(), "{version:?}: curves are written as SPLINE entities");
|
||
let back = leaves(&open(&out.bytes, &points()).document);
|
||
assert_eq!(back.len(), cases.len(), "{version:?}");
|
||
for (n, &(closed, returns)) in back.iter().zip(&cases) {
|
||
let path = n.path_data().unwrap();
|
||
assert_eq!(path.subpaths.len(), 1, "{version:?}");
|
||
assert_eq!(path.subpaths[0].closed, closed, "{version:?}: closed {closed}, returns {returns}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn info_lists_layers_layouts_and_version() {
|
||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
|
||
let i = info(&drawing(&[(9, "$ACADVER"), (1, "AC1015"), (9, "$INSUNITS"), (70, "6")], &tables, &[], LINE)).unwrap();
|
||
assert_eq!(i.version, "2000");
|
||
assert_eq!(i.units, "Meters");
|
||
assert_eq!((i.unit, i.scale), (Unit::Meters, 1.0));
|
||
assert_eq!(i.layers, ["0", "Walls"]);
|
||
assert_eq!(i.layouts, ["Model"]);
|
||
assert!(info(b"not a drawing").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn entities_extruded_along_minus_z_are_mirrored() {
|
||
let e = [(0, "CIRCLE"), (10, "50"), (20, "0"), (40, "10"), (210, "0"), (220, "0"), (230, "-1")];
|
||
let doc = open(&entities(&e), &points()).document;
|
||
let b = bounds(&leaves(&doc)[0]);
|
||
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
|
||
}
|
||
|
||
/// A drawing with one paper block record (handle `A`) and a LAYOUT object per name.
|
||
fn with_layouts(names: &[String]) -> Vec<u8> {
|
||
let mut s = String::from(
|
||
" 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nBLOCK_RECORD\n 0\nBLOCK_RECORD\n 5\nA\n 2\n*Paper_Space\n 0\nENDTAB\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n",
|
||
);
|
||
for n in names {
|
||
s.push_str(&format!(" 0\nLAYOUT\n100\nAcDbLayout\n 1\n{n}\n330\nA\n"));
|
||
}
|
||
s.push_str(" 0\nENDSEC\n 0\nEOF\n");
|
||
s.into_bytes()
|
||
}
|
||
|
||
#[test]
|
||
fn many_paper_layouts_are_read_in_linear_time() {
|
||
let names: Vec<String> = (0..100_000).map(|i| format!("L{i}")).collect();
|
||
let bytes = with_layouts(&names);
|
||
let t = std::time::Instant::now();
|
||
let info = info(&bytes).unwrap();
|
||
assert_eq!(info.layouts.len(), names.len() + 1);
|
||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "layout de-duplication is quadratic: {:?}", t.elapsed());
|
||
}
|
||
|
||
#[test]
|
||
fn paper_layout_names_are_unique_ignoring_case() {
|
||
let names = ["Layout1", "LAYOUT1", "Other"].map(String::from);
|
||
let info = info(&with_layouts(&names)).unwrap();
|
||
assert_eq!(info.layouts, ["Model", "Layout1", "Other"]);
|
||
}
|
||
|
||
#[test]
|
||
fn many_array_inserts_of_a_missing_block_stay_bounded() {
|
||
let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")];
|
||
let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect();
|
||
let bytes = entities(&e);
|
||
let text = reader::decode(&bytes);
|
||
let (pairs, _) = reader::pairs(&text).unwrap();
|
||
let d = reader::Drawing::read(&pairs);
|
||
let model = d.layout("Model").or(d.layouts.first()).unwrap();
|
||
let mut conv = entity::Converter::default();
|
||
let items = conv.items(&d.layout_records(model));
|
||
assert_eq!(items.len(), 3000);
|
||
let total: usize = items.iter().map(|i| if let entity::Geom::Insert(ins) = &i.geom { ins.cells.len() } else { 0 }).sum();
|
||
assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}");
|
||
assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings);
|
||
}
|