Files
OneCAD/crates/io/src/dxf_out.rs
T

895 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The flat pattern's DXF drawing, as AutoCAD 2000 (`AC1015`), the oldest release that has the
//! ELLIPSE and SPLINE entities.
//!
//! Curves stay exact: a circle is a CIRCLE, a circular arc an ARC, a whole ellipse one ELLIPSE
//! and a conic arc of weight below 1 an elliptical ELLIPSE arc; Béziers (TrueType glyph
//! outlines, spline pieces) and the other conics are SPLINEs of their own degree. The file holds
//! what the entities refer to and what release 2000 needs to read them: the version, the
//! handle seed, the units and the extents; the linetype, layer and block-record tables; the
//! model and paper space blocks; handles, owners and the subclass markers of each entity; the
//! root dictionary. Layers can be dashed and a drawing can carry TEXT (for annotations). Group
//! codes follow Autodesk's public DXF reference.
use std::f64::consts::TAU;
use std::fmt::Write as _;
use solidcraft_geom::{Loop2, Seg2, Vec2};
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfLinetype {
#[default]
Continuous,
/// AutoCAD's DASHED: a dash of 12.7 mm and a gap of 6.35 mm.
Dashed,
/// Short 2 mm dashes and 1 mm gaps for hidden edges in millimetre drawings.
Hidden,
}
impl DxfLinetype {
fn name(self) -> &'static str {
match self {
DxfLinetype::Continuous => "Continuous",
DxfLinetype::Dashed => "DASHED",
DxfLinetype::Hidden => "HIDDEN",
}
}
}
/// A layer: its name, AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black) and linetype.
#[derive(Clone, Debug, PartialEq)]
pub struct DxfLayer {
pub name: String,
pub color: i16,
pub linetype: DxfLinetype,
}
impl DxfLayer {
/// A continuous layer.
pub fn new(name: impl Into<String>, color: i16) -> DxfLayer {
DxfLayer { name: name.into(), color, linetype: DxfLinetype::Continuous }
}
/// The same layer with a dashed line.
pub fn dashed(self) -> DxfLayer {
DxfLayer { linetype: DxfLinetype::Dashed, ..self }
}
}
/// The geometry of one entity (millimetres).
#[derive(Clone, Debug, PartialEq)]
pub enum DxfShape {
Line(Vec2, Vec2),
/// Connected straight segments, with continuous linetype generation through the vertices.
Polyline {
points: Vec<Vec2>,
},
Circle {
c: Vec2,
r: f64,
},
/// Counter-clockwise from `a0` to `a1` (degrees).
Arc {
c: Vec2,
r: f64,
a0: f64,
a1: f64,
},
/// An ellipse or an arc of one, counter-clockwise from parameter `t0` to `t1` (radians):
/// the point at `t` is `c + major·cos t + perp(major)·ratio·sin t`, `major` being the
/// vector from the centre to the end of the major axis and `ratio` the minor radius over
/// the major one (0 < ratio ≤ 1). A whole ellipse runs from 0 to 2π.
Ellipse {
c: Vec2,
major: Vec2,
ratio: f64,
t0: f64,
t1: f64,
},
/// A clamped B-spline of `degree` over `ctrl`, rational when `weights` are given.
Spline {
degree: u8,
knots: Vec<f64>,
ctrl: Vec<Vec2>,
weights: Option<Vec<f64>>,
},
/// One line of text, centred on `at` (its baseline) and turned `angle_deg` counter-clockwise.
/// `%%d`, `%%p` and `%%c` are AutoCAD's degree, plus-minus and diameter signs; other letters
/// outside ASCII are written as `\U+XXXX`.
Text {
at: Vec2,
height: f64,
angle_deg: f64,
text: String,
},
}
/// An entity on a layer.
#[derive(Clone, Debug, PartialEq)]
pub struct DxfEntity {
pub layer: String,
pub shape: DxfShape,
}
/// The entity of one profile segment, exactly: a line, an arc (a circle when it is a whole
/// turn), an elliptical arc for a conic of weight below 1, a spline for a Bézier or any other conic.
pub fn seg_shape(s: &Seg2) -> DxfShape {
match *s {
Seg2::Line { a, b } => DxfShape::Line(a, b),
Seg2::Arc { center, radius, start, sweep } => {
if (sweep.abs() - TAU).abs() < 1e-9 {
return DxfShape::Circle { c: center, r: radius };
}
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
DxfShape::Arc { c: center, r: radius, a0: a0.to_degrees().rem_euclid(360.0), a1: a1.to_degrees().rem_euclid(360.0) }
}
Seg2::Conic { a, apex, b, w } => match conic_ellipse(a, apex, b, w) {
Some(e) => DxfShape::Ellipse { c: e.c, major: e.major, ratio: e.ratio, t0: wrap(e.t0), t1: wrap(e.t1) },
None => bezier_spline(&[a, apex, b], ((w - 1.0).abs() > 1e-12).then(|| vec![1.0, w, 1.0])),
},
Seg2::Cubic { p0, p1, p2, p3 } => bezier_spline(&[p0, p1, p2, p3], None),
Seg2::Bezier { n, p } => bezier_spline(p.get(..=(n as usize).clamp(1, 7)).unwrap_or(&p), None),
}
}
/// The entities of a closed loop: one ELLIPSE when it is a whole ellipse of conic arcs, else
/// one entity for each segment.
pub fn loop_shapes(lp: &Loop2) -> Vec<DxfShape> {
let arcs: Option<Vec<EllipseArc>> =
lp.segs.iter().map(|s| if let Seg2::Conic { a, apex, b, w } = *s { conic_ellipse(a, apex, b, w) } else { None }).collect();
if let Some(arcs) = arcs
&& let Some(first) = arcs.first()
&& arcs.iter().all(|e| e.same_ellipse(first))
&& (arcs.iter().map(|e| e.t1 - e.t0).sum::<f64>() - TAU).abs() < 1e-7
{
return vec![DxfShape::Ellipse { c: first.c, major: first.major, ratio: first.ratio, t0: 0.0, t1: TAU }];
}
lp.segs.iter().map(seg_shape).collect()
}
/// A parameter reduced to [0, 2π); one within 1e-12 of a full turn is 0.
fn wrap(t: f64) -> f64 {
let r = t.rem_euclid(TAU);
if r > TAU - 1e-12 { 0.0 } else { r }
}
/// An elliptical arc as DXF describes it (the parameters are not yet reduced to 0…2π).
#[derive(Clone, Copy, Debug)]
struct EllipseArc {
c: Vec2,
major: Vec2,
ratio: f64,
t0: f64,
t1: f64,
}
impl EllipseArc {
fn same_ellipse(&self, o: &EllipseArc) -> bool {
let tol = 1e-7 * self.major.len().max(1.0);
let parallel = self.major.cross(o.major).abs() <= tol * self.major.len();
self.c.dist(o.c) <= tol && parallel && (self.major.len() - o.major.len()).abs() <= tol && (self.ratio - o.ratio).abs() <= 1e-9
}
}
/// The ellipse a rational quadratic Bézier of weight `w` below 1 (end weights 1) lies on. The
/// arc is `c + u·cos θ + v·sin θ` for θ from −α to α, `cos α = w`, with the conjugate
/// semi-diameters `u` (from the centre towards the arc's middle) and `v` (towards its end).
/// With `m` the middle of the chord, `apex − m = u·(1 − w²)/w` and `m − c = w·u`, so
/// `c = m − (apex − m)·w²/(1 − w²)`, and `v = (b − m)/sin α`. The principal axes are where
/// |u cos t + v sin t| is largest and smallest, tan 2t = 2 u·v / (u² − v²).
fn conic_ellipse(a: Vec2, apex: Vec2, b: Vec2, w: f64) -> Option<EllipseArc> {
// Within 1e-6 of 1 the centre is hundreds of metres away: a rational spline is exact too.
if !(w > 1e-9 && w < 1.0 - 1e-6) {
return None;
}
let m = (a + b) * 0.5;
let c = m - (apex - m) * (w * w / (1.0 - w * w));
let alpha = w.acos();
let (u, v) = ((m - c) / w, (b - m) / alpha.sin());
if !(c.is_finite() && u.is_finite() && v.is_finite()) || u.cross(v).abs() < 1e-18 {
return None;
}
let t = 0.5 * (2.0 * u.dot(v)).atan2(u.dot(u) - v.dot(v));
let major = u * t.cos() + v * t.sin();
let minor = v * t.cos() - u * t.sin();
if !(major.len() > 1e-12) {
return None;
}
let ratio = (minor.len() / major.len()).clamp(1e-12, 1.0);
// DXF runs counter-clockwise with the minor axis a quarter turn ahead of the major one. If
// `minor` lies on the other side the arc, which runs a → b with increasing θ, is clockwise.
let (t0, t1) = if major.cross(minor) > 0.0 { (-alpha - t, alpha - t) } else { (t - alpha, t + alpha) };
Some(EllipseArc { c, major, ratio, t0, t1 })
}
/// A Bézier over `pts` (weights `weights`) as a clamped spline of degree `pts.len() − 1`.
fn bezier_spline(pts: &[Vec2], weights: Option<Vec<f64>>) -> DxfShape {
let degree = pts.len().saturating_sub(1).max(1);
let knots = std::iter::repeat_n(0.0, degree + 1).chain(std::iter::repeat_n(1.0, degree + 1)).collect();
DxfShape::Spline { degree: degree as u8, knots, ctrl: pts.to_vec(), weights }
}
impl DxfShape {
/// Points on the shape (or its control polygon) for the drawing's extents.
fn sample(&self, out: &mut Vec<Vec2>) {
match self {
DxfShape::Line(a, b) => out.extend([*a, *b]),
DxfShape::Polyline { points } => out.extend(points.iter().copied()),
DxfShape::Circle { c, r } => out.extend([*c - Vec2::new(*r, *r), *c + Vec2::new(*r, *r)]),
DxfShape::Arc { c, r, a0, a1 } => {
let sweep = (a1 - a0).rem_euclid(360.0);
out.extend((0..=64).map(|i| *c + Vec2::from_angle((a0 + sweep * i as f64 / 64.0).to_radians()) * *r));
}
DxfShape::Ellipse { c, major, ratio, t0, t1 } => {
let (minor, sweep) = (major.perp() * *ratio, if (t1 - t0 - TAU).abs() < 1e-12 { TAU } else { (t1 - t0).rem_euclid(TAU) });
out.extend((0..=128).map(|i| {
let t = t0 + sweep * i as f64 / 128.0;
*c + *major * t.cos() + minor * t.sin()
}));
}
// A spline lies within the hull of its control points.
DxfShape::Spline { ctrl, .. } => out.extend(ctrl.iter().copied()),
// Where a viewer puts the letters depends on its font: 0.8 heights for each.
DxfShape::Text { at, height, angle_deg, text } => {
let (along, up) = (Vec2::from_angle(angle_deg.to_radians()), Vec2::from_angle(angle_deg.to_radians()).perp());
let half = 0.4 * height * text.chars().count() as f64;
out.extend([-half, half].map(|s| *at + along * s));
out.extend([-half, half].map(|s| *at + along * s + up * *height));
}
}
}
}
/// Group codes and handles, in file order.
struct Out {
s: String,
next: u64,
}
impl Out {
fn code(&mut self, c: i32, v: impl std::fmt::Display) {
let _ = write!(self.s, "{c:>3}\n{v}\n");
}
/// A real number: up to twelve decimals, at least one.
fn num(&mut self, c: i32, v: f64) {
let text = format!("{:.12}", if v.is_finite() { v } else { 0.0 });
let text = text.trim_end_matches('0');
let text = if text.ends_with('.') { format!("{text}0") } else { text.to_string() };
self.code(c, if text == "-0.0" { "0.0" } else { text.as_str() });
}
/// A point in the plane at height 0: codes `c`, `c + 10`, `c + 20`.
fn point(&mut self, c: i32, p: Vec2) {
self.num(c, p.x);
self.num(c + 10, p.y);
self.num(c + 20, 0.0);
}
fn handle(&mut self) -> String {
let h = format!("{:X}", self.next);
self.next += 1;
h
}
/// The start of a symbol table; returns its handle.
fn table(&mut self, name: &str, entries: usize) -> String {
let h = self.handle();
self.code(0, "TABLE");
self.code(2, name);
self.code(5, &h);
self.code(330, 0);
self.code(100, "AcDbSymbolTable");
self.code(70, entries);
h
}
/// The start of a symbol table record.
fn record(&mut self, kind: &str, subclass: &str, table: &str) {
let h = self.handle();
self.code(0, kind);
self.code(5, h);
self.code(330, table);
self.code(100, "AcDbSymbolTableRecord");
self.code(100, subclass);
}
/// A BLOCK and its ENDBLK, empty, owned by the block record `owner`.
fn block(&mut self, owner: &str, name: &str, paper: bool) {
for (kind, sub) in [("BLOCK", "AcDbBlockBegin"), ("ENDBLK", "AcDbBlockEnd")] {
let h = self.handle();
self.code(0, kind);
self.code(5, h);
self.code(330, owner);
self.code(100, "AcDbEntity");
if paper {
self.code(67, 1);
}
self.code(8, "0");
self.code(100, sub);
if kind == "BLOCK" {
self.code(2, name);
self.code(70, 0);
self.point(10, Vec2::ZERO);
self.code(3, name);
self.code(1, "");
}
}
}
}
/// A text on one line, as a TEXT entity holds it: at most 250 characters, control characters as
/// spaces, letters outside ASCII as `\U+XXXX` (`?` beyond the first 65536).
fn text_value(s: &str) -> String {
s.chars()
.take(250)
.map(|c| match c {
c if c.is_ascii_control() => " ".to_string(),
c if c.is_ascii() => c.to_string(),
c => u16::try_from(u32::from(c)).map_or_else(|_| "?".to_string(), |u| format!("\\U+{u:04X}")),
})
.collect()
}
/// A DXF drawing (AutoCAD 2000, millimetres) of `entities`. `layers` are the layers they are on
/// (layer `0` is always there).
pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String {
let mut pts = Vec::new();
for e in entities {
e.shape.sample(&mut pts);
}
pts.retain(|p| p.is_finite());
let (lo, hi) = pts
.iter()
.fold(None, |acc: Option<(Vec2, Vec2)>, p| {
Some(acc.map_or((*p, *p), |(lo, hi)| (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))))
})
.unwrap_or((Vec2::ZERO, Vec2::ZERO));
let mut layer_list = vec![DxfLayer::new("0", 7)];
for l in layers {
if !layer_list.iter().any(|x| x.name == l.name) {
layer_list.push(l.clone());
}
}
// Everything but the header, which needs the handle seed.
let mut o = Out { s: String::new(), next: 0x20 };
let (model, paper) = (o.handle(), o.handle());
let root = o.handle();
o.code(0, "SECTION");
o.code(2, "TABLES");
// The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths
// (a gap is negative) after the pattern's total.
let mut linetypes: Vec<(&str, &str, &[f64])> = vec![
("ByBlock", "", &[]),
("ByLayer", "", &[]),
("Continuous", "Solid line", &[]),
("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]),
];
if layer_list.iter().any(|l| l.linetype == DxfLinetype::Hidden) {
linetypes.push(("HIDDEN", "Hidden edges", &[2.0, -1.0]));
}
let t = o.table("LTYPE", linetypes.len());
for (name, description, pattern) in linetypes {
o.record("LTYPE", "AcDbLinetypeTableRecord", &t);
o.code(2, name);
o.code(70, 0);
o.code(3, description);
o.code(72, 65);
o.code(73, pattern.len());
o.num(40, pattern.iter().map(|d| d.abs()).sum());
for d in pattern {
o.num(49, *d);
o.code(74, 0);
}
}
o.code(0, "ENDTAB");
let t = o.table("LAYER", layer_list.len());
for l in &layer_list {
o.record("LAYER", "AcDbLayerTableRecord", &t);
o.code(2, &l.name);
o.code(70, 0);
o.code(62, l.color);
o.code(6, l.linetype.name());
}
o.code(0, "ENDTAB");
// The one text style, which a TEXT entity uses when it names none.
let t = o.table("STYLE", 1);
o.record("STYLE", "AcDbTextStyleTableRecord", &t);
o.code(2, "Standard");
o.code(70, 0);
o.num(40, 0.0);
o.num(41, 1.0);
o.num(50, 0.0);
o.code(71, 0);
o.num(42, 2.5);
o.code(3, "txt");
o.code(4, "");
o.code(0, "ENDTAB");
let t = o.table("BLOCK_RECORD", 2);
for (h, name) in [(&model, "*Model_Space"), (&paper, "*Paper_Space")] {
o.code(0, "BLOCK_RECORD");
o.code(5, h);
o.code(330, &t);
o.code(100, "AcDbSymbolTableRecord");
o.code(100, "AcDbBlockTableRecord");
o.code(2, name);
}
o.code(0, "ENDTAB");
o.code(0, "ENDSEC");
o.code(0, "SECTION");
o.code(2, "BLOCKS");
o.block(&model, "*Model_Space", false);
o.block(&paper, "*Paper_Space", true);
o.code(0, "ENDSEC");
o.code(0, "SECTION");
o.code(2, "ENTITIES");
for e in entities {
let kind = match &e.shape {
DxfShape::Line(..) => "LINE",
DxfShape::Polyline { .. } => "LWPOLYLINE",
DxfShape::Circle { .. } => "CIRCLE",
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
DxfShape::Spline { .. } => "SPLINE",
DxfShape::Text { .. } => "TEXT",
};
let h = o.handle();
o.code(0, kind);
o.code(5, h);
o.code(330, &model);
o.code(100, "AcDbEntity");
o.code(8, if e.layer.trim().is_empty() { "0" } else { e.layer.as_str() });
match &e.shape {
DxfShape::Polyline { points } => {
o.code(100, "AcDbPolyline");
o.code(90, points.len());
// Autodesk DXF reference: bit 128 (PLINEGEN) continues the dash pattern.
o.code(70, 128);
for p in points {
o.num(10, p.x);
o.num(20, p.y);
}
}
DxfShape::Line(a, b) => {
o.code(100, "AcDbLine");
o.point(10, *a);
o.point(11, *b);
}
DxfShape::Circle { c, r } => {
o.code(100, "AcDbCircle");
o.point(10, *c);
o.num(40, *r);
}
DxfShape::Arc { c, r, a0, a1 } => {
o.code(100, "AcDbCircle");
o.point(10, *c);
o.num(40, *r);
o.code(100, "AcDbArc");
o.num(50, *a0);
o.num(51, *a1);
}
DxfShape::Ellipse { c, major, ratio, t0, t1 } => {
o.code(100, "AcDbEllipse");
o.point(10, *c);
o.point(11, *major);
o.num(210, 0.0);
o.num(220, 0.0);
o.num(230, 1.0);
o.num(40, *ratio);
o.num(41, *t0);
o.num(42, *t1);
}
DxfShape::Spline { degree, knots, ctrl, weights } => {
o.code(100, "AcDbSpline");
o.num(210, 0.0);
o.num(220, 0.0);
o.num(230, 1.0);
// Flags: 4 rational, 8 planar.
o.code(70, 8 + if weights.is_some() { 4 } else { 0 });
o.code(71, degree);
o.code(72, knots.len());
o.code(73, ctrl.len());
o.code(74, 0);
// Knots, then weights, then control points: the order of the reference.
for k in knots {
o.num(40, *k);
}
for w in weights.iter().flatten() {
o.num(41, *w);
}
for p in ctrl {
o.point(10, *p);
}
}
DxfShape::Text { at, height, angle_deg, text } => {
o.code(100, "AcDbText");
o.point(10, *at);
o.num(40, *height);
o.code(1, text_value(text));
o.num(50, *angle_deg);
// Centred (72 = 1): a reader that places the text by the second alignment point
// and one that takes the first both put its middle on `at`.
o.code(72, 1);
o.point(11, *at);
o.code(100, "AcDbText");
o.code(73, 0);
}
}
}
o.code(0, "ENDSEC");
o.code(0, "SECTION");
o.code(2, "OBJECTS");
o.code(0, "DICTIONARY");
o.code(5, root.as_str());
o.code(330, 0);
o.code(100, "AcDbDictionary");
o.code(281, 1);
o.code(0, "ENDSEC");
o.code(0, "EOF");
let mut head = Out { s: String::new(), next: 0 };
head.code(0, "SECTION");
head.code(2, "HEADER");
head.code(9, "$ACADVER");
head.code(1, "AC1015");
head.code(9, "$HANDSEED");
head.code(5, format!("{:X}", o.next));
head.code(9, "$INSUNITS");
head.code(70, 4);
head.code(9, "$MEASUREMENT");
head.code(70, 1);
head.code(9, "$EXTMIN");
head.point(10, lo);
head.code(9, "$EXTMAX");
head.point(10, hi);
head.code(0, "ENDSEC");
head.s + &o.s
}
#[cfg(test)]
mod tests {
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI};
use super::*;
use crate::{Geom2, read_dxf};
/// The conic quarter of the ellipse `c ± u`, `c ± v` from `c + u` to `c + v`.
fn quarter(c: Vec2, u: Vec2, v: Vec2) -> Seg2 {
Seg2::Conic { a: c + u, apex: c + u + v, b: c + v, w: FRAC_1_SQRT_2 }
}
/// The ellipse of 50 × 24 round (50, 40): its four quarters, counter-clockwise from (75, 40).
fn oval() -> [Seg2; 4] {
let (c, u, v) = (Vec2::new(50.0, 40.0), Vec2::new(25.0, 0.0), Vec2::new(0.0, 12.0));
[quarter(c, u, v), quarter(c, v, -u), quarter(c, -u, -v), quarter(c, -v, u)]
}
fn ellipse_point(e: &DxfShape, t: f64) -> Vec2 {
let DxfShape::Ellipse { c, major, ratio, .. } = e else { panic!("{e:?}") };
*c + *major * t.cos() + major.perp() * (*ratio * t.sin())
}
/// A DXF text as (group code, value) pairs.
fn pairs(dxf: &str) -> Vec<(i32, String)> {
let lines: Vec<&str> = dxf.lines().collect();
lines.chunks(2).map(|c| (c[0].trim().parse().unwrap(), c.get(1).unwrap_or(&"").trim().to_string())).collect()
}
/// The entities of the ENTITIES section: their type and group codes.
fn entities(dxf: &str) -> Vec<(String, Vec<(i32, String)>)> {
let p = pairs(dxf);
let start = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "ENTITIES")).unwrap();
let mut out: Vec<(String, Vec<(i32, String)>)> = Vec::new();
for (c, v) in &p[start + 1..] {
match (*c, v.as_str()) {
(0, "ENDSEC") => break,
(0, kind) => out.push((kind.to_string(), Vec::new())),
_ => out.last_mut().unwrap().1.push((*c, v.clone())),
}
}
out
}
fn values(e: &(String, Vec<(i32, String)>), code: i32) -> Vec<f64> {
e.1.iter().filter(|(c, _)| *c == code).map(|(_, v)| v.parse().unwrap()).collect()
}
fn near(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
}
#[test]
fn a_conic_arc_below_weight_one_is_an_elliptical_arc() {
// By hand: the first quarter of the 25 × 12 ellipse round (50, 40) runs from (75, 40)
// over the corner (75, 52) to (50, 52) with weight cos 45°. Its centre is
// m − (apex − m)·w²/(1 − w²) = (62.5, 46) − (12.5, 6)·1 = (50, 40); the semi-diameters
// u = (m − c)/w and v = (b − m)/sin 45° are (25, 12) and (−25, 12) over √2, which are
// equally long, so the principal axes are 45° from them: the major one is
// (u − v)/√2 = (25, 0), the minor one (u + v)/√2 = (0, 12), the ratio 12/25 and, from
// (75, 40) to (50, 52), the parameters 0 to π/2.
let [first, second, ..] = oval();
let DxfShape::Ellipse { c, major, ratio, t0, t1 } = seg_shape(&first) else { panic!("{:?}", seg_shape(&first)) };
assert!(c.dist(Vec2::new(50.0, 40.0)) < 1e-9 && major.dist(Vec2::new(25.0, 0.0)) < 1e-9, "{c:?} {major:?}");
assert!((ratio - 0.48).abs() < 1e-12 && t0.abs() < 1e-9 && (t1 - FRAC_PI_2).abs() < 1e-9, "{ratio} {t0} {t1}");
// Whatever axis the second quarter is described by, its arc starts and ends at the
// segment's ends and every point of it lies on the segment.
let tilt = |v: Vec2| Vec2::from_angle(0.3) * v.x + Vec2::from_angle(0.3).perp() * v.y;
let (centre, u, v) = (Vec2::new(5.0, -2.0), tilt(Vec2::new(15.0, 0.0)), tilt(Vec2::new(0.0, 8.0)));
let general = quarter(centre, u, v).split_at(0.3).1;
for seg in [first, second, quarter(centre, u, v), quarter(centre, v, -u).reversed(), general, general.reversed()] {
let e = seg_shape(&seg);
let DxfShape::Ellipse { t0, t1, .. } = e else { panic!("{e:?}") };
let sweep = (t1 - t0).rem_euclid(TAU);
for i in 0..=20 {
let p = ellipse_point(&e, t0 + sweep * i as f64 / 20.0);
assert!(seg.point_at(seg.closest_param(p)).dist(p) < 1e-9, "{seg:?} {p:?}");
}
let ends = [ellipse_point(&e, t0), ellipse_point(&e, t0 + sweep)];
assert!(ends.iter().any(|p| p.dist(seg.start()) < 1e-9) && ends.iter().any(|p| p.dist(seg.end()) < 1e-9), "{ends:?} {seg:?}");
}
}
#[test]
fn four_quarters_are_one_ellipse() {
let quarters = oval();
let reversed = Loop2 { segs: quarters.iter().rev().map(Seg2::reversed).collect() };
let shifted = Loop2 { segs: quarters.iter().cycle().skip(2).take(4).copied().collect() };
for lp in [Loop2 { segs: quarters.to_vec() }, reversed, shifted] {
let shapes = loop_shapes(&lp);
let [DxfShape::Ellipse { c, major, ratio, t0, t1 }] = shapes.as_slice() else { panic!("{shapes:?}") };
assert!(
c.dist(Vec2::new(50.0, 40.0)) < 1e-9 && (major.len() - 25.0).abs() < 1e-9 && major.cross(Vec2::X).abs() < 1e-9,
"{c:?} {major:?}"
);
assert!((ratio - 0.48).abs() < 1e-12 && *t0 == 0.0 && *t1 == TAU, "{ratio} {t0} {t1}");
}
// Three quarters and a line are pieces: three ELLIPSE arcs and a LINE.
let (q, line) = (oval(), Seg2::Line { a: Vec2::new(50.0, 28.0), b: Vec2::new(50.0, 28.0) + Vec2::X });
let open = loop_shapes(&Loop2 { segs: vec![q[0], q[1], q[2], line] });
assert!(matches!(open.as_slice(), [DxfShape::Ellipse { t1, .. }, _, _, DxfShape::Line(..)] if (*t1 - FRAC_PI_2).abs() < 1e-9), "{open:?}");
// A circle of arcs is the caller's business, but a whole turn is one CIRCLE.
let turn = Seg2::Arc { center: Vec2::new(3.0, 4.0), radius: 2.0, start: -PI, sweep: -TAU };
assert_eq!(seg_shape(&turn), DxfShape::Circle { c: Vec2::new(3.0, 4.0), r: 2.0 });
}
#[test]
fn beziers_and_the_other_conics_are_splines() {
let p = [Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(3.0, 3.0), Vec2::new(4.0, 0.0)];
let cubic = seg_shape(&Seg2::Cubic { p0: p[0], p1: p[1], p2: p[2], p3: p[3] });
assert_eq!(cubic, DxfShape::Spline { degree: 3, knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], ctrl: p.to_vec(), weights: None });
// A quadratic (a glyph curve) is a parabola: degree 2, not rational. Weight 2 is a
// hyperbola: rational. Weight 1 − 10⁻⁹ is no ellipse a reader could place: rational too.
let conic = |w: f64| seg_shape(&Seg2::Conic { a: p[0], apex: p[1], b: p[3], w });
let degree_2 = |s: &DxfShape, weights: Option<Vec<f64>>| {
*s == DxfShape::Spline { degree: 2, knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], ctrl: vec![p[0], p[1], p[3]], weights }
};
assert!(degree_2(&conic(1.0), None));
assert!(degree_2(&conic(2.0), Some(vec![1.0, 2.0, 1.0])));
assert!(degree_2(&conic(1.0 - 1e-9), Some(vec![1.0, 1.0 - 1e-9, 1.0])));
// A quintic piece: six control points and twelve knots.
let mut q = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL];
for (i, s) in q.iter_mut().enumerate() {
*s = Vec2::new(i as f64, (i * i) as f64);
}
let DxfShape::Spline { degree, knots, ctrl, weights } = seg_shape(&Seg2::Bezier { n: 5, p: q }) else { panic!() };
assert_eq!((degree, knots.len(), ctrl.len(), weights), (5, 12, 6, None));
assert!(knots[..6].iter().all(|k| *k == 0.0) && knots[6..].iter().all(|k| *k == 1.0) && ctrl[5] == q[5]);
}
#[test]
fn drawings_carry_version_units_extents_and_handles() {
let ents = vec![
DxfEntity { layer: "OUTLINE".into(), shape: DxfShape::Line(Vec2::new(-1.0, 2.0), Vec2::new(10.0, 2.0)) },
DxfEntity { layer: "OUTLINE".into(), shape: DxfShape::Circle { c: Vec2::new(5.0, 5.0), r: 4.0 } },
DxfEntity { layer: "CUT".into(), shape: DxfShape::Line(Vec2::ZERO, Vec2::X) },
];
let dxf = write_dxf_drawing(&[DxfLayer::new("OUTLINE", 7), DxfLayer::new("CUT", 1)], &ents);
let p = pairs(&dxf);
let after = |var: &str| &p[p.iter().position(|(c, v)| (*c, v.as_str()) == (9, var)).unwrap() + 1..];
assert_eq!(after("$ACADVER")[0], (1, "AC1015".to_string()));
assert_eq!(after("$INSUNITS")[0], (70, "4".to_string()));
// The circle reaches (1, 1) to (9, 9), the first line starts at x = −1 and the last at y = 0.
assert_eq!(&after("$EXTMIN")[..3], &[(10, "-1.0".to_string()), (20, "0.0".to_string()), (30, "0.0".to_string())]);
assert_eq!(&after("$EXTMAX")[..2], &[(10, "10.0".to_string()), (20, "9.0".to_string())]);
// Layer 0 and the two layers are in the LAYER table, each entity is on one of them,
// and the entities belong to the model space block record.
let layer_table = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "LAYER")).unwrap();
let names: Vec<&str> = p[layer_table..]
.iter()
.take_while(|(c, v)| (*c, v.as_str()) != (0, "ENDTAB"))
.filter(|(c, _)| *c == 2)
.map(|(_, v)| v.as_str())
.collect();
assert_eq!(names, ["LAYER", "0", "OUTLINE", "CUT"]);
// (The first "*Model_Space" is the BLOCK_RECORD, whose handle is four pairs before its name.)
let model_at = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "*Model_Space")).unwrap();
let model = p[model_at - 4].1.clone();
assert_eq!(p[model_at - 4].0, 5);
for e in entities(&dxf) {
assert!(e.1.contains(&(330, model.clone())) && e.1.contains(&(100, "AcDbEntity".to_string())), "{e:?}");
assert!(names.contains(&e.1.iter().find(|(c, _)| *c == 8).unwrap().1.as_str()));
}
// Handles are unique and the seed is above all of them.
let seed_at = p.iter().position(|(c, v)| (*c, v.as_str()) == (9, "$HANDSEED")).unwrap() + 1;
let handles: Vec<u64> =
p.iter().enumerate().filter(|(i, (c, _))| *i != seed_at && *c == 5).map(|(_, (_, v))| u64::from_str_radix(v, 16).unwrap()).collect();
let mut sorted = handles.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), handles.len());
assert!(handles.len() > ents.len() && handles.iter().all(|h| *h < u64::from_str_radix(&p[seed_at].1, 16).unwrap()));
assert!(dxf.ends_with(" 0\nEOF\n"));
// No entities: a valid, empty drawing.
assert_eq!(entities(&write_dxf_drawing(&[], &[])).len(), 0);
}
#[test]
fn what_is_written_is_what_the_reader_gets_back() {
let quarter_one = seg_shape(&oval()[0]);
let cubic = [Vec2::new(0.0, 0.0), Vec2::new(5.0, 10.0), Vec2::new(15.0, 10.0), Vec2::new(20.0, 0.0)];
let shapes = vec![
DxfShape::Line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)),
DxfShape::Circle { c: Vec2::new(5.0, 5.0), r: 2.0 },
DxfShape::Arc { c: Vec2::ZERO, r: 3.0, a0: 0.0, a1: 90.0 },
loop_shapes(&Loop2 { segs: oval().to_vec() }).remove(0),
quarter_one,
seg_shape(&Seg2::Cubic { p0: cubic[0], p1: cubic[1], p2: cubic[2], p3: cubic[3] }),
];
let ents: Vec<DxfEntity> = shapes.into_iter().map(|shape| DxfEntity { layer: "0".into(), shape }).collect();
let dxf = write_dxf_drawing(&[], &ents);
let back = read_dxf(&dxf).unwrap();
assert_eq!(back.len(), 6, "{back:?}");
assert!(matches!(&back[0], Geom2::Line(a, b) if a.dist(Vec2::ZERO) < 1e-9 && b.dist(Vec2::new(10.0, 0.0)) < 1e-9));
assert!(matches!(&back[1], Geom2::Circle(c, r) if c.dist(Vec2::new(5.0, 5.0)) < 1e-9 && (r - 2.0).abs() < 1e-9));
assert!(
matches!(&back[2], Geom2::Arc { c, a, b } if c.dist(Vec2::ZERO) < 1e-9 && a.dist(Vec2::new(3.0, 0.0)) < 1e-9 && b.dist(Vec2::new(0.0, 3.0)) < 1e-9)
);
// The whole ellipse: centre, end of the major axis, minor radius.
assert!(
matches!(&back[3], Geom2::Ellipse { c, major, minor } if c.dist(Vec2::new(50.0, 40.0)) < 1e-9 && major.dist(Vec2::new(75.0, 40.0)) < 1e-9 && (minor - 12.0).abs() < 1e-9),
"{:?}",
back[3]
);
// The reader makes an elliptical arc a spline through points of it: from (75, 40) to (50, 52).
let Geom2::Spline { pts, control: false, .. } = &back[4] else { panic!("{:?}", back[4]) };
let on_ellipse = |p: &Vec2| (((p.x - 50.0) / 25.0).powi(2) + ((p.y - 40.0) / 12.0).powi(2) - 1.0).abs() < 1e-9;
assert!(
pts.first().is_some_and(|p| p.dist(Vec2::new(75.0, 40.0)) < 1e-9) && pts.last().is_some_and(|p| p.dist(Vec2::new(50.0, 52.0)) < 1e-9)
);
assert!(pts.iter().all(on_ellipse));
// The cubic: degree and control points.
let Geom2::Spline { pts, control: true, degree: 3 } = &back[5] else { panic!("{:?}", back[5]) };
assert!(pts.iter().zip(&cubic).all(|(a, b)| a.dist(*b) < 1e-9) && pts.len() == 4);
// The numbers in the file itself, which the reader does not keep: the ellipse...
let file = entities(&dxf);
assert_eq!(file.iter().map(|e| e.0.as_str()).collect::<Vec<_>>(), ["LINE", "CIRCLE", "ARC", "ELLIPSE", "ELLIPSE", "SPLINE"]);
assert!(near(&values(&file[3], 10), &[50.0]) && near(&values(&file[3], 20), &[40.0]) && near(&values(&file[3], 11), &[25.0]));
assert!(near(&values(&file[3], 21), &[0.0]) && near(&values(&file[3], 40), &[0.48]) && near(&values(&file[3], 41), &[0.0]));
assert!(near(&values(&file[3], 42), &[TAU]) && near(&values(&file[3], 230), &[1.0]));
// ...the elliptical arc from parameter 0 to π/2...
assert!(near(&values(&file[4], 41), &[0.0]) && near(&values(&file[4], 42), &[FRAC_PI_2]) && near(&values(&file[4], 40), &[0.48]));
// ...and the spline: planar, degree 3, eight knots, four control points.
let spline = &file[5];
assert_eq!(values(spline, 70), [8.0]);
assert_eq!((values(spline, 71), values(spline, 72), values(spline, 73), values(spline, 74)), (vec![3.0], vec![8.0], vec![4.0], vec![0.0]));
assert!(near(&values(spline, 40), &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]));
assert!(near(&values(spline, 10), &[0.0, 5.0, 15.0, 20.0]) && near(&values(spline, 20), &[0.0, 10.0, 10.0, 0.0]));
assert!(values(spline, 41).is_empty());
}
#[test]
fn a_rational_spline_lists_knots_then_weights_then_control_points() {
let hyperbola = seg_shape(&Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 2.0), b: Vec2::new(3.0, 0.0), w: 2.0 });
let dxf = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape: hyperbola }]);
let e = &entities(&dxf)[0];
assert_eq!(values(e, 70), [12.0]);
assert!(near(&values(e, 41), &[1.0, 2.0, 1.0]) && near(&values(e, 40), &[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]));
let at = |code: i32| e.1.iter().position(|(c, _)| *c == code).unwrap();
let last = |code: i32| e.1.iter().rposition(|(c, _)| *c == code).unwrap();
assert!(last(40) < at(41) && last(41) < at(10), "{:?}", e.1);
}
/// The records of a symbol table, each as its group codes after the type.
fn table_records(p: &[(i32, String)], table: &str) -> Vec<Vec<(i32, String)>> {
let start = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, table)).unwrap();
let mut out: Vec<Vec<(i32, String)>> = Vec::new();
for (c, v) in &p[start + 1..] {
match (*c, v.as_str()) {
(0, "ENDTAB") => break,
(0, _) => out.push(Vec::new()),
_ => {
if let Some(record) = out.last_mut() {
record.push((*c, v.clone()));
}
}
}
}
out
}
fn field(record: &[(i32, String)], code: i32) -> &str {
record.iter().find(|(c, _)| *c == code).map_or("", |(_, v)| v.as_str())
}
#[test]
fn a_dashed_layer_names_its_linetype_and_the_table_defines_it() {
let dxf = write_dxf_drawing(&[DxfLayer::new("BEND", 1).dashed(), DxfLayer::new("CUT", 4)], &[]);
let p = pairs(&dxf);
// The linetypes of every drawing, then DASHED: a dash of 12.7 mm and a gap of 6.35 mm, 19.05 mm in all.
let types = table_records(&p, "LTYPE");
assert_eq!(types.iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["ByBlock", "ByLayer", "Continuous", "DASHED"]);
let dashed = &types[3];
let numbers = |code: i32| dashed.iter().filter(|(c, _)| *c == code).map(|(_, v)| v.parse::<f64>().unwrap()).collect::<Vec<_>>();
assert!(near(&numbers(49), &[12.7, -6.35]) && near(&numbers(40), &[19.05]) && near(&numbers(73), &[2.0]) && near(&numbers(72), &[65.0]));
// The table says how many records it holds.
let at = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "LTYPE")).unwrap();
assert_eq!(p[at..].iter().find(|(c, _)| *c == 70).map(|(_, v)| v.as_str()), Some("4"));
// Each layer names its linetype.
let layers = table_records(&p, "LAYER");
assert_eq!(
layers.iter().map(|r| (field(r, 2), field(r, 62), field(r, 6))).collect::<Vec<_>>(),
[("0", "7", "Continuous"), ("BEND", "1", "DASHED"), ("CUT", "4", "Continuous")]
);
}
#[test]
fn hidden_polylines_continue_short_dashes_across_chords_and_round_trip() {
let points = vec![Vec2::ZERO, Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)];
let layer = DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) };
let text = write_dxf_drawing(&[layer], &[DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } }]);
let records = entities(&text);
assert_eq!(records[0].0, "LWPOLYLINE");
assert_eq!(field(&records[0].1, 70), "128");
assert_eq!(field(&records[0].1, 90), "4");
let types = table_records(&pairs(&text), "LTYPE");
let hidden = types.iter().find(|r| field(r, 2) == "HIDDEN").unwrap();
assert_eq!(hidden.iter().filter(|(c, _)| *c == 49).map(|(_, v)| v.parse::<f64>().unwrap()).collect::<Vec<_>>(), [2., -1.]);
assert_eq!(read_dxf(&text).unwrap().len(), 3);
}
#[test]
fn text_is_centred_on_its_point_and_the_reader_gets_it_back() {
let at = Vec2::new(30.0, 12.0);
let note = DxfShape::Text { at, height: 3.0, angle_deg: 90.0, text: "AB%%d\nC\u{b0}".into() };
let dxf = write_dxf_drawing(&[DxfLayer::new("NOTES", 8)], &[DxfEntity { layer: "NOTES".into(), shape: note }]);
let file = entities(&dxf);
let [text] = file.as_slice() else { panic!("{file:?}") };
assert_eq!(text.0, "TEXT");
// One line (the newline is a space), the sign outside ASCII a code of its own, `%%d` AutoCAD's degree sign.
assert_eq!(text.1.iter().find(|(c, _)| *c == 1).map(|(_, v)| v.as_str()), Some("AB%%d C\\U+00B0"));
// Centred (72 = 1) on the second alignment point, which is the first as well; 3 mm high, turned 90°.
assert!(
near(&values(text, 11), &[30.0])
&& near(&values(text, 21), &[12.0])
&& near(&values(text, 10), &[30.0])
&& near(&values(text, 20), &[12.0])
);
assert!(
near(&values(text, 72), &[1.0]) && near(&values(text, 73), &[0.0]) && near(&values(text, 40), &[3.0]) && near(&values(text, 50), &[90.0])
);
// Its style is in the STYLE table, and the layer it is on in the LAYER table.
let p = pairs(&dxf);
assert_eq!(table_records(&p, "STYLE").iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["Standard"]);
assert!(text.1.contains(&(8, "NOTES".to_string())));
// The reader finds the text where it was put, as high and as turned.
let back = read_dxf(&dxf).unwrap();
assert!(
matches!(&back[..], [Geom2::Text { at: found, height, angle, .. }] if found.dist(at) < 1e-9 && (height - 3.0).abs() < 1e-9 && (angle - FRAC_PI_2).abs() < 1e-9),
"{back:?}"
);
}
#[test]
fn the_extents_hold_the_text() {
// Five letters of 3 mm, turned to read upwards: 0.8 × 3 × 5 = 12 mm long around y = 12, and
// 3 mm to the left of the baseline at x = 30.
let shape = DxfShape::Text { at: Vec2::new(30.0, 12.0), height: 3.0, angle_deg: 90.0, text: "ABCDE".into() };
let dxf = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]);
let p = pairs(&dxf);
let after = |var: &str| &p[p.iter().position(|(c, v)| (*c, v.as_str()) == (9, var)).unwrap() + 1..];
assert_eq!(&after("$EXTMIN")[..2], &[(10, "27.0".to_string()), (20, "6.0".to_string())]);
assert_eq!(&after("$EXTMAX")[..2], &[(10, "30.0".to_string()), (20, "18.0".to_string())]);
}
}