mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
993 lines
51 KiB
Rust
993 lines
51 KiB
Rust
//! DXF output read back as group codes: versions, entities, colours, units, layers, type, images.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
|
||
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes};
|
||
|
||
use crate::*;
|
||
|
||
type Pairs = Vec<(i32, String)>;
|
||
|
||
/// The `(code, value)` pairs of a DXF file.
|
||
fn pairs(dxf: &[u8]) -> Pairs {
|
||
let text = std::str::from_utf8(dxf).expect("UTF-8");
|
||
let lines: Vec<&str> = text.lines().collect();
|
||
assert_eq!(lines.len() % 2, 0, "group codes and values come in pairs");
|
||
lines.chunks(2).map(|p| (p[0].trim().parse().expect("a group code"), p[1].to_string())).collect()
|
||
}
|
||
|
||
/// The objects of section `name`: each the pairs from its `0` code on.
|
||
fn section(p: &[(i32, String)], name: &str) -> Vec<Pairs> {
|
||
let start = p.windows(2).position(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into()));
|
||
let Some(start) = start else { return vec![] };
|
||
let mut out: Vec<Pairs> = vec![];
|
||
for pair in &p[start + 2..] {
|
||
if *pair == (0, "ENDSEC".into()) {
|
||
break;
|
||
}
|
||
if pair.0 == 0 {
|
||
out.push(vec![]);
|
||
}
|
||
if let Some(o) = out.last_mut() {
|
||
o.push(pair.clone());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn kind(o: &[(i32, String)]) -> &str {
|
||
o.first().map_or("", |p| p.1.as_str())
|
||
}
|
||
|
||
fn get(o: &[(i32, String)], code: i32) -> Option<&str> {
|
||
o.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
|
||
}
|
||
|
||
fn all(o: &[(i32, String)], code: i32) -> Vec<f64> {
|
||
o.iter().filter(|p| p.0 == code).map(|p| p.1.trim().parse().expect("a number")).collect()
|
||
}
|
||
|
||
fn header(p: &[(i32, String)], var: &str) -> Option<String> {
|
||
let i = p.iter().position(|x| *x == (9, var.into()))?;
|
||
p.get(i + 1).map(|x| x.1.clone())
|
||
}
|
||
|
||
fn entities(out: &DxfOutput) -> Vec<Pairs> {
|
||
section(&pairs(&out.bytes), "ENTITIES")
|
||
}
|
||
|
||
fn kinds(out: &DxfOutput) -> Vec<String> {
|
||
entities(out).iter().map(|e| kind(e).to_string()).collect()
|
||
}
|
||
|
||
fn table(out: &DxfOutput, kind_: &str) -> Vec<Pairs> {
|
||
section(&pairs(&out.bytes), "TABLES").into_iter().filter(|o| kind(o) == kind_).collect()
|
||
}
|
||
|
||
/// A 200 × 100 pt document whose first layer holds what `nodes` makes.
|
||
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
|
||
let mut d = Document::new(200.0, 100.0);
|
||
let nodes = nodes(&mut d);
|
||
let layer = d.layers[0].id;
|
||
for n in nodes {
|
||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||
}
|
||
d
|
||
}
|
||
|
||
fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node {
|
||
Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width))
|
||
}
|
||
|
||
/// The options with the first artboard as the region, in points.
|
||
fn opts(d: &Document) -> DxfOptions {
|
||
DxfOptions { region: d.artboards[0].rect, unit: Unit::Points, ..DxfOptions::default() }
|
||
}
|
||
|
||
/// A red-filled rectangle with a black 1 pt stroke, and an unfilled blue 2 pt ellipse.
|
||
fn shapes_doc() -> Document {
|
||
doc_with(|d| {
|
||
vec![
|
||
path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0),
|
||
path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0),
|
||
]
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn every_version_declares_its_acadver_and_is_well_formed() {
|
||
let d = shapes_doc();
|
||
for v in DxfVersion::ALL {
|
||
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
|
||
let p = pairs(&out.bytes);
|
||
assert_eq!(header(&p, "$ACADVER").as_deref(), Some(v.acadver()), "{v:?}");
|
||
assert_eq!(p.last(), Some(&(0, "EOF".to_string())), "{v:?}");
|
||
let opened = p.iter().filter(|x| **x == (0, "SECTION".into())).count();
|
||
assert_eq!(opened, p.iter().filter(|x| **x == (0, "ENDSEC".into())).count(), "{v:?}: balanced sections");
|
||
assert!(!entities(&out).is_empty(), "{v:?}");
|
||
if v >= DxfVersion::R13 {
|
||
// Every handle is unique and below $HANDSEED.
|
||
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
|
||
let handles: Vec<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
|
||
let unique: std::collections::HashSet<_> = handles.iter().collect();
|
||
assert_eq!(unique.len(), handles.len(), "{v:?}: unique handles");
|
||
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
|
||
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
|
||
assert!(section(&p, "OBJECTS").iter().any(|o| kind(o) == "DICTIONARY"), "{v:?}: root dictionary");
|
||
} else {
|
||
assert!(header(&p, "$HANDSEED").is_none());
|
||
}
|
||
}
|
||
assert_eq!(DxfVersion::from_id("r2013"), Some(DxfVersion::R2013));
|
||
assert_eq!(DxfVersion::from_id("AC1009"), Some(DxfVersion::R12));
|
||
assert_eq!(DxfVersion::from_id("2019"), None);
|
||
}
|
||
|
||
/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table
|
||
/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes
|
||
/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section:
|
||
/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C).
|
||
#[test]
|
||
fn r2000_and_later_files_have_the_structure_cad_apps_require() {
|
||
use std::collections::HashSet;
|
||
// Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in
|
||
// the first and the UCS type in the second).
|
||
let layout_codes: HashSet<i32> = [0, 5, 102, 330, 360, 100]
|
||
.into_iter()
|
||
.chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333])
|
||
.chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346])
|
||
.collect();
|
||
let d = shapes_doc();
|
||
for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) {
|
||
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
|
||
let p = pairs(&out.bytes);
|
||
for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] {
|
||
assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section");
|
||
}
|
||
let tables = section(&p, "TABLES");
|
||
let names = |k: &str| -> Vec<String> { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() };
|
||
for (k, want) in [
|
||
("LTYPE", "ByBlock"),
|
||
("LTYPE", "ByLayer"),
|
||
("LTYPE", "Continuous"),
|
||
("LAYER", "0"),
|
||
("STYLE", "Standard"),
|
||
("APPID", "ACAD"),
|
||
("DIMSTYLE", "Standard"),
|
||
("BLOCK_RECORD", "*Model_Space"),
|
||
("BLOCK_RECORD", "*Paper_Space"),
|
||
] {
|
||
assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}");
|
||
}
|
||
// Every object after the header has a handle below $HANDSEED, and every reference to
|
||
// another object (owner 330, soft/hard pointers 340/350/390) names one of them.
|
||
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
|
||
let handles: HashSet<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
|
||
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
|
||
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
|
||
for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) {
|
||
let h = u32::from_str_radix(value, 16).unwrap();
|
||
assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}");
|
||
}
|
||
let objects = section(&p, "OBJECTS");
|
||
let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect();
|
||
assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1");
|
||
for l in &layouts {
|
||
let bad: Vec<i32> = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect();
|
||
assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}");
|
||
// The paper image origin is 148 (x) and 149 (y).
|
||
assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin");
|
||
// Flag 1024 marks the model space layout, and only it.
|
||
let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str());
|
||
let flags: i64 = get(l, 70).unwrap().parse().unwrap();
|
||
assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}");
|
||
}
|
||
// The plot style placeholder carries only the common object codes.
|
||
let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder");
|
||
assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn r12_viewport_has_no_duplicate_group_codes() {
|
||
let d = shapes_doc();
|
||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
let vport = table(&out, "VPORT").into_iter().find(|o| kind(o) == "VPORT").expect("VPORT table record");
|
||
let codes: Vec<i32> = vport.iter().map(|pair| pair.0).collect();
|
||
let unique: std::collections::HashSet<_> = codes.iter().collect();
|
||
assert_eq!(unique.len(), codes.len(), "R12 VPORT record has duplicate group codes: {vport:?}");
|
||
let z = vport.iter().find(|pair| pair.0 == 36).map(|pair| pair.1.trim().parse::<f64>().unwrap());
|
||
assert_eq!(z, Some(1.0), "the view direction looks down the z axis");
|
||
}
|
||
|
||
#[test]
|
||
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||
let d = shapes_doc();
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
assert_eq!(
|
||
kinds(&out),
|
||
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
|
||
"the fill's outline and hatch, then the stroke, then the circle's stroke"
|
||
);
|
||
let e = entities(&out);
|
||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||
let corners = |poly: &Pairs| {
|
||
let mut xs = all(poly, 10);
|
||
xs.sort_by(f64::total_cmp);
|
||
let mut ys = all(poly, 20);
|
||
ys.sort_by(f64::total_cmp);
|
||
(xs, ys)
|
||
};
|
||
let rect = (vec![10.0, 10.0, 110.0, 110.0], vec![40.0, 40.0, 90.0, 90.0]);
|
||
for poly in [&e[0], &e[2]] {
|
||
assert_eq!(get(poly, 70), Some("1"), "closed");
|
||
assert_eq!(corners(poly), rect);
|
||
}
|
||
// Laser and cutter software skips hatches: the fill's outline comes first, without a lineweight.
|
||
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
|
||
let hatch = &e[1];
|
||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||
// The circular ellipse is preserved as a native circle (#1299).
|
||
let circle = &e[3];
|
||
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
|
||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
|
||
assert_eq!(get(&entities(&r14)[2], 370), None);
|
||
assert_eq!(get(&entities(&r14)[0], 43), None, "a fill's outline has no width");
|
||
// R13 has splines and hatches, and polylines of vertices.
|
||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
|
||
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
|
||
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
|
||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
let k = kinds(&r12);
|
||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
|
||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn circles_and_circular_arcs_are_native_dxf_entities() {
|
||
let d = doc_with(|d| {
|
||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||
let c = Point::new(120.0, 50.0);
|
||
let r = 30.0;
|
||
let k = r * shapes::KAPPA;
|
||
let mut arc = vectorcraft_geom::BezPath::new();
|
||
arc.move_to(Point::new(c.x + r, c.y));
|
||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||
vec![
|
||
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||
]
|
||
});
|
||
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
|
||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||
let e = entities(&out);
|
||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
|
||
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
|
||
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
|
||
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
|
||
}
|
||
}
|
||
|
||
/// The path `p` with `xf` applied.
|
||
fn transformed(p: &PathData, xf: Affine) -> PathData {
|
||
PathData::from_bezpath(&(xf * p.to_bezpath()))
|
||
}
|
||
|
||
fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||
}
|
||
|
||
#[test]
|
||
fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() {
|
||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||
let d = doc_with(|d| {
|
||
let stroke = || Paint::solid(Color::BLACK);
|
||
vec![
|
||
// 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5.
|
||
path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0),
|
||
// A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3.
|
||
path(
|
||
d,
|
||
transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))),
|
||
Paint::None,
|
||
stroke(),
|
||
1.0,
|
||
),
|
||
// The first ellipse turned 30° about its center, filled: the outline is the ellipse too.
|
||
path(
|
||
d,
|
||
transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))),
|
||
Paint::solid(Color::BLACK),
|
||
Paint::None,
|
||
0.0,
|
||
),
|
||
]
|
||
});
|
||
for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) {
|
||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||
let e = entities(&out);
|
||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}");
|
||
let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42));
|
||
let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]);
|
||
assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}");
|
||
assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn");
|
||
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]);
|
||
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]);
|
||
// Turned 30° counterclockwise on screen, so clockwise with DXF's y up.
|
||
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]);
|
||
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]);
|
||
assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}");
|
||
}
|
||
// R12 has no ELLIPSE: the curves are flattened polylines.
|
||
let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap());
|
||
assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}");
|
||
// A thin ellipse keeps its ratio in full (six decimals would write 0.000002).
|
||
let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||
let e = entities(&export(&thin, &opts(&thin)).unwrap());
|
||
assert_eq!(kind(&e[0]), "ELLIPSE");
|
||
assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]);
|
||
}
|
||
|
||
#[test]
|
||
fn exported_ellipses_read_back_as_the_same_ellipse() {
|
||
let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0));
|
||
let d = doc_with(|d| {
|
||
vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||
});
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
assert_eq!(kinds(&out), ["ELLIPSE"]);
|
||
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() };
|
||
let back = import(&out.bytes, &o).unwrap().document;
|
||
let mut paths = vec![];
|
||
back.walk(|n| {
|
||
if let NodeKind::Path { path, .. } = &n.kind {
|
||
paths.push(path.to_bezpath());
|
||
}
|
||
});
|
||
assert_eq!(paths.len(), 1);
|
||
assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed");
|
||
// Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered.
|
||
let bp = &paths[0];
|
||
let found = bp.bounding_box().center();
|
||
let inverse = xf.inverse();
|
||
let original = Point::new(60.0, 40.0);
|
||
for seg in bp.segments() {
|
||
for i in 0..=16 {
|
||
let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2();
|
||
let q = inverse * p;
|
||
let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0);
|
||
assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() {
|
||
// A rounded square: four cubics through the ellipse's anchors with longer handles.
|
||
let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8);
|
||
let mut squircle = vectorcraft_geom::BezPath::new();
|
||
squircle.move_to(Point::new(c.x + r, c.y));
|
||
squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||
squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||
squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||
squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y));
|
||
squircle.close_path();
|
||
// An egg: one half wider than the other.
|
||
let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath();
|
||
let mut els: Vec<kurbo::PathEl> = egg.elements().to_vec();
|
||
if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) {
|
||
a.x -= 10.0;
|
||
b.x -= 10.0;
|
||
}
|
||
let d = doc_with(|d| {
|
||
vec![
|
||
path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||
path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6).
|
||
path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||
]
|
||
});
|
||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_lone_straight_segment_is_a_line() {
|
||
let segment = |closed: bool, extra: Option<Point>| {
|
||
let mut b = vectorcraft_geom::BezPath::new();
|
||
b.move_to(Point::new(20.0, 30.0));
|
||
b.line_to(Point::new(120.0, 70.0));
|
||
if let Some(p) = extra {
|
||
b.line_to(p);
|
||
}
|
||
if closed {
|
||
b.close_path();
|
||
}
|
||
PathData::from_bezpath(&b)
|
||
};
|
||
let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||
for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
|
||
let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap());
|
||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["LINE"], "{version:?}");
|
||
assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}");
|
||
assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom");
|
||
assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}");
|
||
}
|
||
|
||
// Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight
|
||
// before 2000 keeps its polyline width.
|
||
let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]);
|
||
let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap());
|
||
assert_eq!(r12(&filled), ["LINE"]);
|
||
assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]);
|
||
let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap());
|
||
assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1")));
|
||
|
||
// A closed segment and two segments stay polylines.
|
||
for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] {
|
||
let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn r12_stroked_circles_keep_polyline_width() {
|
||
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
|
||
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
let e = entities(&out);
|
||
assert_eq!(kind(&e[0]), "POLYLINE");
|
||
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
|
||
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
|
||
}
|
||
|
||
#[test]
|
||
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||
let d = doc_with(|d| {
|
||
let c = Point::new(50.0, 50.0);
|
||
let r = 30.0;
|
||
let k = r * shapes::KAPPA;
|
||
let mut arc = vectorcraft_geom::BezPath::new();
|
||
arc.move_to(Point::new(c.x + r, c.y));
|
||
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||
arc.close_path();
|
||
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||
});
|
||
|
||
let modern = export(&d, &opts(&d)).unwrap();
|
||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
|
||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_closed_sliver_along_a_circle_is_not_a_circle() {
|
||
let d = doc_with(|d| {
|
||
let (cx, cy, r) = (60.0, 250.0, 200.0);
|
||
let at = |deg: f64| {
|
||
let rad = deg.to_radians();
|
||
Point::new(cx + r * rad.cos(), cy + r * rad.sin())
|
||
};
|
||
let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| {
|
||
let p0 = at(deg0);
|
||
let p3 = at(deg1);
|
||
let step = (deg1 - deg0).to_radians();
|
||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||
let r0 = deg0.to_radians();
|
||
let r1 = deg1.to_radians();
|
||
let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k);
|
||
let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k);
|
||
sliver.curve_to(p1, p2, p3);
|
||
};
|
||
let mut sliver = vectorcraft_geom::BezPath::new();
|
||
sliver.move_to(at(-110.0));
|
||
along(&mut sliver, -110.0, -90.0);
|
||
along(&mut sliver, -90.0, -70.0);
|
||
along(&mut sliver, -70.0, -110.0);
|
||
sliver.close_path();
|
||
vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||
});
|
||
|
||
let modern = export(&d, &opts(&d)).unwrap();
|
||
assert_ne!(kinds(&modern), ["CIRCLE"]);
|
||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||
}
|
||
|
||
#[test]
|
||
fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() {
|
||
let d = doc_with(|d| {
|
||
let mut b = vectorcraft_geom::BezPath::new();
|
||
b.move_to(Point::new(-8.404028665134, 62.061475842818));
|
||
b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0));
|
||
vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||
});
|
||
let dxf = export(&d, &opts(&d)).unwrap();
|
||
assert_ne!(kinds(&dxf), ["ARC"]);
|
||
assert_eq!(kinds(&dxf), ["SPLINE"]);
|
||
}
|
||
|
||
#[test]
|
||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||
let make_doc = |keep_closure: bool| {
|
||
doc_with(|d| {
|
||
cases
|
||
.iter()
|
||
.map(|&(closed, returns)| {
|
||
let start = Point::new(20.0, 20.0);
|
||
let mut b = kurbo::BezPath::new();
|
||
b.move_to(start);
|
||
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 {
|
||
if keep_closure {
|
||
b.close_path();
|
||
} else if !returns {
|
||
// The open reference has the same final straight segment.
|
||
b.line_to(start);
|
||
}
|
||
}
|
||
path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 0.0)), 2.0)
|
||
})
|
||
.collect()
|
||
})
|
||
};
|
||
let closed = make_doc(true);
|
||
let open = make_doc(false);
|
||
for version in DxfVersion::ALL {
|
||
let out = export(&closed, &DxfOptions { version, ..opts(&closed) }).unwrap();
|
||
let actual = entities(&out);
|
||
if version == DxfVersion::R12 {
|
||
let polys: Vec<_> = actual.iter().filter(|e| kind(e) == "POLYLINE").collect();
|
||
assert_eq!(polys.len(), cases.len());
|
||
for (poly, &(closed, _)) in polys.iter().zip(&cases) {
|
||
assert_eq!(get(poly, 70), Some(if closed { "1" } else { "0" }));
|
||
}
|
||
assert!(!actual.iter().any(|e| kind(e) == "SPLINE"));
|
||
continue;
|
||
}
|
||
let reference = entities(&export(&open, &DxfOptions { version, ..opts(&open) }).unwrap());
|
||
assert_eq!(actual.len(), cases.len());
|
||
for ((spline, reference), &(closed, _)) in actual.iter().zip(&reference).zip(&cases) {
|
||
assert_eq!(kind(spline), "SPLINE");
|
||
assert_eq!(get(spline, 70), Some(if closed { "9" } else { "8" }), "{version:?}");
|
||
let mut normalized = spline.clone();
|
||
normalized.iter_mut().filter(|p| p.0 == 70).for_each(|p| p.1 = "8".into());
|
||
assert_eq!(&normalized, reference, "all other entity properties, knots and controls: {version:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn colours_map_to_the_index_palette_or_true_colour() {
|
||
let d = shapes_doc();
|
||
let style = |o: &DxfOutput, i: usize| {
|
||
let e = &entities(o)[i];
|
||
(get(e, 62).map(str::to_string), get(e, 420).map(str::to_string))
|
||
};
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000");
|
||
assert_eq!(style(&out, 1), style(&out, 0), "the fill's outline has the fill's colour");
|
||
assert_eq!(style(&out, 2), (Some("7".into()), Some("0".into())), "black is index 7");
|
||
assert_eq!(style(&out, 3), (Some("5".into()), Some("255".into())));
|
||
for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] {
|
||
let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap();
|
||
assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}");
|
||
}
|
||
let orange = doc_with(|d| {
|
||
vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::from_hex("#ff8000").unwrap()), Paint::None, 0.0)]
|
||
});
|
||
let aci = |depth| get(&entities(&export(&orange, &DxfOptions { colors: depth, ..opts(&orange) }).unwrap())[0], 62).unwrap().to_string();
|
||
assert_eq!(
|
||
(aci(ColorDepth::Aci256), aci(ColorDepth::Aci16), aci(ColorDepth::Aci8)),
|
||
("30".into(), "30".into(), "2".into()),
|
||
"8 colours: yellow is nearest"
|
||
);
|
||
// True colour needs 2004: earlier versions fall back to indices, and say so.
|
||
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
|
||
assert_eq!(style(&old, 0), (Some("1".into()), None));
|
||
assert!(old.warnings.iter().any(|w| w.contains("true colour")));
|
||
}
|
||
|
||
#[test]
|
||
fn units_scale_coordinates_and_set_insunits() {
|
||
let d = shapes_doc();
|
||
let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS");
|
||
let max_x = |o: &DxfOutput| all(&entities(o)[2], 10).into_iter().fold(f64::MIN, f64::max);
|
||
let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap();
|
||
assert_eq!(insunits(&mm).as_deref(), Some("4"));
|
||
assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5);
|
||
assert_eq!(header(&pairs(&mm.bytes), "$MEASUREMENT").as_deref(), Some("1"));
|
||
let inches = export(&d, &DxfOptions { unit: Unit::Inches, ..opts(&d) }).unwrap();
|
||
assert_eq!(insunits(&inches).as_deref(), Some("1"));
|
||
// 1 in = 10 units: unitless, coordinates ten times larger.
|
||
let scaled = export(&d, &DxfOptions { unit: Unit::Inches, scale: 10.0, ..opts(&d) }).unwrap();
|
||
assert_eq!(insunits(&scaled).as_deref(), Some("0"));
|
||
assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5);
|
||
// Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked.
|
||
assert_eq!(get(&entities(&scaled)[2], 370), Some("35"));
|
||
let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap();
|
||
assert_eq!(get(&entities(&heavy)[2], 370), Some("70"));
|
||
assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err());
|
||
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn layers_become_dxf_layers() {
|
||
let mut d = shapes_doc();
|
||
let second = d.add_layer(Some("Dims: <top>"));
|
||
let hidden = d.add_layer(Some("Notes"));
|
||
let template = d.add_layer(Some("Tracing"));
|
||
for l in [second, hidden, template] {
|
||
let n = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
}
|
||
if let Some(n) = d.node_mut(hidden) {
|
||
n.visible = false;
|
||
}
|
||
if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) {
|
||
*template = true;
|
||
}
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
let layers: Vec<(String, String)> =
|
||
table(&out, "LAYER").iter().map(|o| (get(o, 2).unwrap().to_string(), get(o, 62).unwrap().to_string())).collect();
|
||
let names: Vec<&str> = layers.iter().map(|l| l.0.as_str()).collect();
|
||
assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out");
|
||
assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off");
|
||
let on: Vec<String> = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect();
|
||
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Dims_ _top_", "Notes", "Notes"]);
|
||
// Before 2000: capitals, letters, digits and $-_ only.
|
||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
let names: Vec<String> = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect();
|
||
assert_eq!(names, ["0", "LAYER_1", "DIMS___TOP_", "NOTES"]);
|
||
}
|
||
|
||
#[test]
|
||
fn dashes_become_linetypes_and_opacity_transparency() {
|
||
let d = doc_with(|d| {
|
||
let mut n = path(d, shapes::line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||
if let Some(st) = n.appearance.stroke_mut() {
|
||
st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
|
||
}
|
||
n.opacity = 0.5;
|
||
vec![n]
|
||
});
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
let e = &entities(&out)[0];
|
||
let alpha = (0x0200_0000 | 128).to_string();
|
||
assert_eq!((get(e, 6), get(e, 440)), (Some("DASHED1"), Some(alpha.as_str())));
|
||
let lt = table(&out, "LTYPE").into_iter().find(|o| get(o, 2) == Some("DASHED1")).unwrap();
|
||
assert_eq!(all(<, 49), [6.0, -3.0]);
|
||
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
|
||
assert!(get(&entities(&old)[0], 440).is_none());
|
||
assert!(old.warnings.iter().any(|w| w.contains("transparency")));
|
||
}
|
||
|
||
#[test]
|
||
fn alter_paths_writes_strokes_as_filled_outlines() {
|
||
let fill = ["LWPOLYLINE", "HATCH"];
|
||
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
|
||
// Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc.
|
||
let mut d = shapes_doc();
|
||
let c = Point::new(150.0, 95.0);
|
||
let k = 20.0 * shapes::KAPPA;
|
||
let mut arc = vectorcraft_geom::BezPath::new();
|
||
arc.move_to(Point::new(c.x + 20.0, c.y));
|
||
arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0));
|
||
let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||
let layer = d.layers[0].id;
|
||
d.insert(Some(layer), usize::MAX, arc).unwrap();
|
||
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
|
||
assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat());
|
||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||
// R12 polylines carry any width, so its strokes stay lines too.
|
||
let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
assert!(!kinds(&r12).iter().any(|k| k == "HATCH"));
|
||
assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2")));
|
||
// R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch.
|
||
let d = shapes_doc();
|
||
let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
|
||
// The rectangle's stroke: an outer and an inner boundary.
|
||
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
|
||
// A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width.
|
||
let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]);
|
||
let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap();
|
||
assert_eq!(kinds(&out), rect_stroke);
|
||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||
let lines = export(&wide, &opts(&wide)).unwrap();
|
||
assert_eq!(kinds(&lines), ["LWPOLYLINE"]);
|
||
assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm")));
|
||
// To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line.
|
||
for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] {
|
||
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]);
|
||
assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt");
|
||
}
|
||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||
let mut d = shapes_doc();
|
||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||
}
|
||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
|
||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||
// With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line.
|
||
let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap();
|
||
assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||
assert!(altered.warnings.is_empty(), "{:?}", altered.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn dashes_that_start_part_way_into_their_pattern_are_outlined() {
|
||
let dashed = |offset: f64| {
|
||
doc_with(|d| {
|
||
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||
if let Some(st) = n.appearance.stroke_mut() {
|
||
st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false });
|
||
}
|
||
vec![n]
|
||
})
|
||
};
|
||
// A linetype starts at the start of its pattern: an offset of a whole period (the odd
|
||
// pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch.
|
||
for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] {
|
||
let d = dashed(offset);
|
||
let k = kinds(&export(&d, &opts(&d)).unwrap());
|
||
if outlined {
|
||
assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}");
|
||
} else {
|
||
assert_eq!(k, ["LWPOLYLINE"], "offset {offset}");
|
||
}
|
||
let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap());
|
||
assert_eq!(altered, k, "offset {offset}, Alter Paths");
|
||
}
|
||
// Maximize Editability keeps the line and its linetype.
|
||
let d = dashed(4.0);
|
||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
assert_eq!(kinds(&editable), ["LWPOLYLINE"]);
|
||
assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1"));
|
||
}
|
||
|
||
#[test]
|
||
fn dashed_polylines_continue_their_pattern_around_corners() {
|
||
let rect = |dash: Option<Dash>| {
|
||
doc_with(|d| {
|
||
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||
if let Some(st) = n.appearance.stroke_mut() {
|
||
st.dash = dash.clone();
|
||
}
|
||
vec![n]
|
||
})
|
||
};
|
||
let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string));
|
||
// Group 70 bit 128 makes the linetype run on through the vertices instead of restarting
|
||
// at each one; a solid polyline keeps only its closed bit.
|
||
let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }));
|
||
for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] {
|
||
assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129"));
|
||
}
|
||
let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap();
|
||
assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129")));
|
||
let solid = rect(None);
|
||
assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1"));
|
||
}
|
||
|
||
fn text_doc(text: &str, size: f64) -> Document {
|
||
doc_with(|d| {
|
||
let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() };
|
||
let t = TextObject::point(Point::new(10.0, 50.0), text, style);
|
||
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn type_is_text_or_outlines() {
|
||
let d = text_doc("Plan A", 20.0);
|
||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
let e = entities(&editable);
|
||
assert_eq!(e.len(), 1);
|
||
assert_eq!((kind(&e[0]), get(&e[0], 1)), ("TEXT", Some("Plan A")));
|
||
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "the cap height of 20 pt type");
|
||
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 50.0), "the baseline start, y up");
|
||
let family = CharStyle::default().font_family;
|
||
let style = table(&editable, "STYLE").into_iter().find(|o| get(o, 3) == Some(&*format!("{family}.ttf"))).unwrap();
|
||
assert_eq!(get(&e[0], 7), get(&style, 2));
|
||
// Outline Text, and Preserve Appearance: the glyphs as filled outlines.
|
||
for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] {
|
||
let k = kinds(&export(&d, &o).unwrap());
|
||
assert!(k.iter().any(|k| k == "HATCH"), "{k:?}");
|
||
assert!(k.iter().all(|k| matches!(k.as_str(), "HATCH" | "LWPOLYLINE" | "SPLINE")), "{k:?}");
|
||
assert_eq!(k.first().map(String::as_str), Some("SPLINE"), "a glyph's outline before its hatch");
|
||
}
|
||
}
|
||
|
||
/// A list paragraph's bullet is a TEXT entity of its own where it is drawn, and the paragraph's
|
||
/// text starts after it, at the left indent.
|
||
#[test]
|
||
fn a_bullet_is_text_of_its_own_before_its_paragraph() {
|
||
let mut d = text_doc("Item", 20.0);
|
||
let id = d.layers[0].children().unwrap()[0].id;
|
||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||
t.para.left_indent = 18.0;
|
||
t.para.first_line_indent = -18.0;
|
||
}
|
||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
let e = entities(&out);
|
||
assert_eq!(kinds(&out), ["TEXT", "TEXT"]);
|
||
assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("\u{2022}"), Some("Item")));
|
||
assert_eq!((all(&e[0], 10)[0], all(&e[1], 10)[0]), (10.0, 28.0), "the bullet at the anchor, the text 18 pt on");
|
||
// In a right-to-left paragraph the bullet is drawn after the text, at the right, and its tab
|
||
// lies between the two.
|
||
let mut d = text_doc("שלום", 20.0);
|
||
let id = d.layers[0].children().unwrap()[0].id;
|
||
let mut bullet_x = f64::NAN;
|
||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||
let m = &lay.markers[0];
|
||
let bullet = m.glyphs.clone().find(|&i| i != m.tab).unwrap();
|
||
assert!(lay.glyphs[m.tab].origin.x < lay.glyphs[bullet].origin.x, "the tab before the bullet");
|
||
bullet_x = 10.0 + lay.glyphs[bullet].origin.x;
|
||
}
|
||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
let e = entities(&out);
|
||
assert_eq!(kinds(&out), ["TEXT", "TEXT"]);
|
||
assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("שלום"), Some("\u{2022}")));
|
||
assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]);
|
||
}
|
||
|
||
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
|
||
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
|
||
/// number's left.
|
||
#[test]
|
||
fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||
let numbered = |text: &str, count: u32| {
|
||
let mut d = text_doc(text, 20.0);
|
||
let id = d.layers[0].children().unwrap()[0].id;
|
||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
|
||
t.para.left_indent = 30.0;
|
||
t.para.first_line_indent = -30.0;
|
||
t.list_count = count;
|
||
}
|
||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
|
||
let rtl = numbered("שלום", 11);
|
||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
|
||
}
|
||
|
||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||
#[test]
|
||
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
|
||
let mut d = text_doc("Plan A, plan B and plan C", 20.0);
|
||
let id = d.layers[0].children().unwrap()[0].id;
|
||
let mut fit = 1.0;
|
||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 30.0)) };
|
||
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
|
||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
|
||
fit = lay.fit_scale;
|
||
}
|
||
let out = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
let e = entities(&out);
|
||
assert!(!e.is_empty() && e.iter().all(|e| kind(e) == "TEXT"), "{:?}", kinds(&out));
|
||
for e in &e {
|
||
assert!((all(e, 40)[0] - 14.0 * fit).abs() < 1e-6, "the cap height of {} pt type: {:?}", 20.0 * fit, all(e, 40));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn text_outside_ascii_is_escaped_before_2007() {
|
||
let d = text_doc("Größe", 12.0);
|
||
let text = |v| {
|
||
let out = export(&d, &DxfOptions { version: v, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||
get(&entities(&out)[0], 1).unwrap().to_string()
|
||
};
|
||
assert_eq!(text(DxfVersion::R2004), "Gr\\U+00F6\\U+00DFe");
|
||
assert_eq!(text(DxfVersion::R2018), "Größe");
|
||
}
|
||
|
||
#[test]
|
||
fn images_link_png_or_jpeg_files() {
|
||
let mut png = vec![];
|
||
image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let mut d = doc_with(|d| {
|
||
let im = ImageObject {
|
||
key: "raster-1".into(),
|
||
width: 4,
|
||
height: 2,
|
||
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
|
||
link: None,
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
};
|
||
vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))]
|
||
});
|
||
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
let e = entities(&out);
|
||
assert_eq!(kind(&e[0]), "IMAGE");
|
||
// The bottom-left corner (10, 30) is (10, 70) y up; a pixel is 5 units.
|
||
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 70.0));
|
||
assert_eq!((all(&e[0], 11)[0], all(&e[0], 22)[0], all(&e[0], 13)[0], all(&e[0], 23)[0]), (5.0, 5.0, 4.0, 2.0));
|
||
assert_eq!(out.images.len(), 1);
|
||
assert!(out.images[0].name.ends_with(".png") && out.images[0].bytes.starts_with(b"\x89PNG"));
|
||
let p = pairs(&out.bytes);
|
||
let objects = section(&p, "OBJECTS");
|
||
let def = objects.iter().find(|o| kind(o) == "IMAGEDEF").unwrap();
|
||
assert_eq!(get(def, 1), Some(out.images[0].name.as_str()));
|
||
assert_eq!(get(&e[0], 340), get(def, 5), "the image points at its definition");
|
||
assert!(objects.iter().any(|o| kind(o) == "IMAGEDEF_REACTOR"));
|
||
assert!(section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE")));
|
||
let jpeg = export(&d, &DxfOptions { raster: RasterFormat::Jpeg, ..opts(&d) }).unwrap();
|
||
assert!(jpeg.images[0].name.ends_with(".jpg") && jpeg.images[0].bytes.starts_with(&[0xFF, 0xD8]));
|
||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||
assert!(entities(&r12).is_empty() && r12.images.is_empty());
|
||
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
||
}
|
||
|
||
#[test]
|
||
fn images_without_pixels_are_left_out_with_one_note() {
|
||
// One image has no data in the document, and the other image's data is empty.
|
||
let mut d = doc_with(|d| {
|
||
["raster-1", "raster-2"]
|
||
.into_iter()
|
||
.map(|key| {
|
||
let im = ImageObject {
|
||
key: key.into(),
|
||
width: 4,
|
||
height: 2,
|
||
xf: Affine::IDENTITY,
|
||
link: None,
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
};
|
||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||
})
|
||
.collect()
|
||
});
|
||
d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None });
|
||
let out = export(&d, &opts(&d)).unwrap();
|
||
assert!(entities(&out).is_empty() && out.images.is_empty());
|
||
assert_eq!(out.warnings, ["an image has no pixels and was left out"]);
|
||
}
|
||
|
||
#[test]
|
||
fn crop_leaves_out_art_outside_the_region() {
|
||
let d = shapes_doc();
|
||
// Only the ellipse (120..180) reaches into 115..200.
|
||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
|
||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn crop_keeps_an_instance_whose_art_reaches_the_region() {
|
||
// The symbol's red rectangle, (100, 50) to (140, 90), drawn from (-50, -50): on the artboard,
|
||
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
|
||
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
|
||
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
|
||
let o = DxfOptions { crop: true, ..opts(&d) };
|
||
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
|
||
}
|