Skip to main content

ogeom_io/
dxf.rs

1//! DXF: 2D drawings, the interchange the field expects.
2//!
3//! Reading takes a drawing's curves as written ([`read_dxf_entities`]: lines,
4//! arcs, circles, ellipses, splines and bulged polylines, with the units and
5//! which layers are dashed), or as polylines ([`read_dxf`]).
6//!
7//! R12 ASCII, the most widely readable dialect: a TABLES section declaring
8//! the two linetypes and two layers a technical drawing needs, then one
9//! `POLYLINE` per curve. Visible curves go on the `VISIBLE` layer with
10//! continuous lines; hidden curves on `HIDDEN`, dashed. The writer takes
11//! bare polylines rather than a drawing type, so anything that produces 2D
12//! curves (the hidden-line projector, a section outline, a sketch) writes
13//! without this crate knowing where they came from.
14
15use ogeom_math::{Point2, Vector2};
16use std::fmt::Write as _;
17
18/// Write polylines as an R12 DXF document.
19///
20/// `visible` draws continuous on layer `VISIBLE`; `hidden` draws dashed on
21/// layer `HIDDEN`. Polylines with fewer than two points are skipped; a
22/// point is not a line in a drawing, here as everywhere.
23#[must_use]
24pub fn write_dxf(visible: &[Vec<Point2>], hidden: &[Vec<Point2>]) -> String {
25    let mut out = String::new();
26    // Header: R12 says almost nothing and needs almost nothing.
27    push(
28        &mut out,
29        &[("0", "SECTION"), ("2", "HEADER"), ("0", "ENDSEC")],
30    );
31
32    // Tables: the linetypes first, because the layers name them.
33    push(&mut out, &[("0", "SECTION"), ("2", "TABLES")]);
34    push(&mut out, &[("0", "TABLE"), ("2", "LTYPE"), ("70", "2")]);
35    push(
36        &mut out,
37        &[
38            ("0", "LTYPE"),
39            ("2", "CONTINUOUS"),
40            ("70", "0"),
41            ("3", "Solid line"),
42            ("72", "65"),
43            ("73", "0"),
44            ("40", "0.0"),
45        ],
46    );
47    push(
48        &mut out,
49        &[
50            ("0", "LTYPE"),
51            ("2", "DASHED"),
52            ("70", "0"),
53            ("3", "Dashed line"),
54            ("72", "65"),
55            ("73", "2"),
56            ("40", "0.75"),
57            ("49", "0.5"),
58            ("49", "-0.25"),
59        ],
60    );
61    push(&mut out, &[("0", "ENDTAB")]);
62    push(&mut out, &[("0", "TABLE"), ("2", "LAYER"), ("70", "2")]);
63    push(
64        &mut out,
65        &[
66            ("0", "LAYER"),
67            ("2", "VISIBLE"),
68            ("70", "0"),
69            ("62", "7"),
70            ("6", "CONTINUOUS"),
71        ],
72    );
73    push(
74        &mut out,
75        &[
76            ("0", "LAYER"),
77            ("2", "HIDDEN"),
78            ("70", "0"),
79            ("62", "8"),
80            ("6", "DASHED"),
81        ],
82    );
83    push(&mut out, &[("0", "ENDTAB"), ("0", "ENDSEC")]);
84
85    push(&mut out, &[("0", "SECTION"), ("2", "ENTITIES")]);
86    for (layer, curves) in [("VISIBLE", visible), ("HIDDEN", hidden)] {
87        for curve in curves {
88            if curve.len() < 2 {
89                continue;
90            }
91            push(
92                &mut out,
93                &[("0", "POLYLINE"), ("8", layer), ("66", "1"), ("70", "0")],
94            );
95            for p in curve {
96                push(&mut out, &[("0", "VERTEX"), ("8", layer)]);
97                let _ = writeln!(out, "10\n{}\n20\n{}\n30\n0.0", real(p.x), real(p.y));
98            }
99            push(&mut out, &[("0", "SEQEND")]);
100        }
101    }
102    push(&mut out, &[("0", "ENDSEC"), ("0", "EOF")]);
103    out
104}
105
106/// A DXF real: shortest exact form, decimal point guaranteed.
107fn real(v: f64) -> String {
108    let s = format!("{v:?}");
109    if s.contains('.') || s.contains('e') {
110        s
111    } else {
112        format!("{s}.0")
113    }
114}
115
116/// Append group-code/value pairs, one per line each.
117fn push(out: &mut String, pairs: &[(&str, &str)]) {
118    for (code, value) in pairs {
119        let _ = writeln!(out, "{code}\n{value}");
120    }
121}
122
123#[cfg(test)]
124#[allow(clippy::unwrap_used)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn a_drawing_writes_layers_polylines_and_exact_coordinates() {
130        let visible = vec![vec![
131            Point2::new(0.0, 0.0),
132            Point2::new(10.0, 0.0),
133            Point2::new(10.0, 5.0),
134        ]];
135        let hidden = vec![vec![Point2::new(1.5, 2.25), Point2::new(3.0, 2.25)]];
136        let text = write_dxf(&visible, &hidden);
137
138        assert!(text.starts_with("0\nSECTION"));
139        assert!(text.trim_end().ends_with("EOF"));
140        assert_eq!(text.matches("POLYLINE").count(), 2);
141        assert_eq!(text.matches("VERTEX").count(), 5);
142        assert_eq!(text.matches("SEQEND").count(), 2);
143        // Both layers declared and used, hidden dashed.
144        assert!(text.contains("VISIBLE"));
145        assert!(text.contains("HIDDEN"));
146        assert!(text.contains("DASHED"));
147        // Coordinates exact and point-carrying.
148        assert!(text.contains("10\n10.0\n20\n5.0"));
149        assert!(text.contains("10\n1.5\n20\n2.25"));
150    }
151
152    #[test]
153    fn degenerate_polylines_are_dropped() {
154        let text = write_dxf(&[vec![Point2::new(1.0, 1.0)]], &[vec![]]);
155        assert_eq!(text.matches("POLYLINE").count(), 0);
156    }
157}
158
159/// The polylines a DXF carries, by the layer they were drawn on.
160#[derive(Debug, Clone, Default, PartialEq)]
161pub struct DxfDrawing {
162    /// Curves on the `VISIBLE` layer, or on no named layer at all.
163    pub visible: Vec<Vec<Point2>>,
164    /// Curves on the `HIDDEN` layer.
165    pub hidden: Vec<Vec<Point2>>,
166}
167
168/// Read the polylines out of an ASCII DXF.
169///
170/// `POLYLINE` and `LWPOLYLINE` become polylines, a closed one ending on
171/// its first point again; `LINE` becomes a polyline of two points. A
172/// polyline's bulges are read as straight chords here: the typed reader,
173/// [`read_dxf_entities`], keeps them and every curve besides. Hidden
174/// curves are those on the `HIDDEN` layer or drawn in a dashed or hidden
175/// linetype.
176///
177/// # Errors
178///
179/// As [`read_dxf_entities`].
180pub fn read_dxf(text: &str) -> ogeom_core::OgeomResult<DxfDrawing> {
181    let mut out = DxfDrawing::default();
182    for entity in read_dxf_entities(text)?.entities {
183        let points = match entity.curve {
184            DxfCurve::Line { start, end } => vec![start, end],
185            DxfCurve::Polyline { vertices, closed } => {
186                let mut points: Vec<Point2> = vertices.iter().map(|v| v.0).collect();
187                if closed
188                    && points.len() > 2
189                    && let (Some(first), Some(last)) = (points.first(), points.last())
190                    && first.distance(*last) > 0.0
191                {
192                    points.push(*first);
193                }
194                points
195            }
196            _ => continue,
197        };
198        if points.len() < 2 {
199            continue;
200        }
201        if entity.hidden {
202            out.hidden.push(points);
203        } else {
204            out.visible.push(points);
205        }
206    }
207    Ok(out)
208}
209
210/// A DXF's drawing entities, typed, with the drawing's units.
211#[derive(Debug, Clone, Default, PartialEq)]
212pub struct DxfEntities {
213    /// `$INSUNITS` from the HEADER, when present (0 is unitless).
214    pub insunits: Option<i32>,
215    /// Millimetres per drawing unit, when `insunits` names a length unit.
216    pub unit_mm: Option<f64>,
217    /// The ENTITIES section's curves, in file order.
218    pub entities: Vec<DxfEntity>,
219}
220
221/// One entity: the curve, and where it was drawn.
222#[derive(Debug, Clone, PartialEq)]
223pub struct DxfEntity {
224    /// Group 8, as written (`"0"` when absent).
225    pub layer: String,
226    /// Whether the layer is `HIDDEN`, or the entity's or its layer's
227    /// linetype is dashed or hidden.
228    pub hidden: bool,
229    /// The curve.
230    pub curve: DxfCurve,
231}
232
233/// A DXF curve in the drawing's plane.
234#[derive(Debug, Clone, PartialEq)]
235pub enum DxfCurve {
236    /// A segment.
237    Line {
238        /// Where it starts.
239        start: Point2,
240        /// Where it ends.
241        end: Point2,
242    },
243    /// Counter-clockwise from `start_angle` to `end_angle`, in radians.
244    Arc {
245        /// The centre.
246        centre: Point2,
247        /// The radius.
248        radius: f64,
249        /// Where it starts, radians.
250        start_angle: f64,
251        /// Where it ends, radians.
252        end_angle: f64,
253    },
254    /// A full circle.
255    Circle {
256        /// The centre.
257        centre: Point2,
258        /// The radius.
259        radius: f64,
260    },
261    /// An ellipse or elliptic arc, parameters as DXF gives them: a full
262    /// ellipse when they span two pi.
263    Ellipse {
264        /// The centre.
265        centre: Point2,
266        /// The major axis's end, relative to the centre.
267        major: Vector2,
268        /// Minor over major.
269        ratio: f64,
270        /// Start parameter.
271        start_param: f64,
272        /// End parameter.
273        end_param: f64,
274    },
275    /// A B-spline as written: degree, knots, control points and weights.
276    /// A spline given only by fit points carries them as its control
277    /// points and no knots.
278    Spline {
279        /// The degree.
280        degree: usize,
281        /// The knot vector.
282        knots: Vec<f64>,
283        /// The control points, or the fit points where there are none.
284        control_points: Vec<Point2>,
285        /// The weights, for a rational spline.
286        weights: Option<Vec<f64>>,
287        /// Whether the spline is closed.
288        closed: bool,
289    },
290    /// A `POLYLINE` or `LWPOLYLINE`: each vertex with the bulge of the
291    /// segment that starts at it (the tangent of a quarter of the included
292    /// angle, positive counter-clockwise).
293    Polyline {
294        /// The vertices and their bulges.
295        vertices: Vec<(Point2, f64)>,
296        /// Whether the last vertex joins the first.
297        closed: bool,
298    },
299}
300
301/// Millimetres per unit for a `$INSUNITS` code that names a length.
302fn unit_mm(code: i32) -> Option<f64> {
303    Some(match code {
304        1 => 25.4,
305        2 => 304.8,
306        3 => 1_609_344.0,
307        4 => 1.0,
308        5 => 10.0,
309        6 => 1000.0,
310        7 => 1.0e6,
311        8 => 2.54e-5,
312        9 => 0.0254,
313        10 => 914.4,
314        11 => 1.0e-7,
315        12 => 1.0e-6,
316        13 => 1.0e-3,
317        14 => 100.0,
318        15 => 1.0e4,
319        16 => 1.0e5,
320        17 => 1.0e12,
321        18 => 1.495_978_707e14,
322        19 => 9.460_730_472_580_8e18,
323        20 => 3.085_677_581_491_367e19,
324        _ => return None,
325    })
326}
327
328/// One group-coded record: the entity or table entry a `0` code opens, and
329/// the pairs up to the next.
330struct Record<'a> {
331    kind: &'a str,
332    pairs: Vec<(i32, &'a str)>,
333}
334
335impl Record<'_> {
336    fn text(&self, code: i32) -> Option<&str> {
337        self.pairs.iter().find(|p| p.0 == code).map(|p| p.1)
338    }
339
340    fn real(&self, code: i32) -> Option<f64> {
341        self.text(code).and_then(|v| v.parse().ok())
342    }
343
344    fn int(&self, code: i32) -> Option<i64> {
345        self.text(code).and_then(|v| v.parse().ok())
346    }
347
348    fn point(&self, x: i32, y: i32) -> Option<Point2> {
349        Some(Point2::new(self.real(x)?, self.real(y)?))
350    }
351
352    fn reals(&self, code: i32) -> Vec<f64> {
353        self.pairs
354            .iter()
355            .filter(|p| p.0 == code)
356            .filter_map(|p| p.1.parse().ok())
357            .collect()
358    }
359
360    /// Points given as repeated `x`/`y` pairs, in order.
361    fn points(&self, x: i32, y: i32) -> Vec<Point2> {
362        let mut out: Vec<Point2> = Vec::new();
363        for (code, value) in &self.pairs {
364            let Ok(v) = value.parse::<f64>() else {
365                continue;
366            };
367            if *code == x {
368                out.push(Point2::new(v, 0.0));
369            } else if *code == y
370                && let Some(last) = out.last_mut()
371            {
372                last.y = v;
373            }
374        }
375        out
376    }
377}
378
379/// Whether a linetype name draws hidden lines.
380fn dashed(linetype: &str) -> bool {
381    let name = linetype.to_ascii_uppercase();
382    name.contains("HIDDEN") || name.contains("DASH")
383}
384
385/// Read the typed entities, the units and the layers' linetypes out of an
386/// ASCII DXF.
387///
388/// Lines, arcs, circles, ellipses, splines, `POLYLINE` and `LWPOLYLINE`
389/// are read from the ENTITIES section, with a polyline's closed flag and
390/// bulges; a `POLYLINE` header's own point is its elevation, not a
391/// vertex. An entity whose extrusion is `(0, 0, -1)` is seen from below,
392/// and its planar coordinates are mirrored in x to the drawing's own view.
393/// Blocks, inserts, text, dimensions and hatches are skipped.
394///
395/// # Errors
396///
397/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
398/// file is not group-coded in pairs, or an entity's extrusion is neither
399/// up nor down, or an ellipse or spline is seen from below.
400pub fn read_dxf_entities(text: &str) -> ogeom_core::OgeomResult<DxfEntities> {
401    // A DXF is a stream of (code, value) pairs, one per line each.
402    let lines: Vec<&str> = text.lines().map(str::trim).collect();
403    if !lines.len().is_multiple_of(2) && !lines.last().is_some_and(|l| l.is_empty()) {
404        ogeom_core::ogeom_bail!(
405            Construction,
406            "a DXF is group codes and values in pairs; this has an odd number of lines"
407        );
408    }
409    let mut pairs: Vec<(i32, &str)> = Vec::with_capacity(lines.len() / 2);
410    for [code, value] in lines.as_chunks::<2>().0 {
411        let Ok(code) = code.parse::<i32>() else {
412            ogeom_core::ogeom_bail!(
413                Construction,
414                "a DXF group code is an integer; found {code:?}"
415            );
416        };
417        pairs.push((code, *value));
418    }
419
420    // Records within each section, by name.
421    let mut sections: Vec<(&str, Vec<Record<'_>>)> = Vec::new();
422    let mut k = 0;
423    while k < pairs.len() {
424        if pairs[k] == (0, "SECTION") && k + 1 < pairs.len() && pairs[k + 1].0 == 2 {
425            let name = pairs[k + 1].1;
426            k += 2;
427            let mut records: Vec<Record<'_>> = Vec::new();
428            // The pairs before the section's first 0 code (the HEADER's
429            // variables) make a record of their own.
430            let mut current = Record {
431                kind: "",
432                pairs: Vec::new(),
433            };
434            while k < pairs.len() && pairs[k] != (0, "ENDSEC") {
435                if pairs[k].0 == 0 {
436                    records.push(core::mem::replace(
437                        &mut current,
438                        Record {
439                            kind: pairs[k].1,
440                            pairs: Vec::new(),
441                        },
442                    ));
443                } else {
444                    current.pairs.push(pairs[k]);
445                }
446                k += 1;
447            }
448            records.push(current);
449            sections.push((name, records));
450        }
451        k += 1;
452    }
453
454    let mut out = DxfEntities::default();
455    let mut layer_linetype: std::collections::HashMap<String, String> =
456        std::collections::HashMap::new();
457    for (name, records) in &sections {
458        match *name {
459            "HEADER" => {
460                for record in records {
461                    let mut it = record.pairs.iter();
462                    while let Some((code, value)) = it.next() {
463                        if *code == 9
464                            && *value == "$INSUNITS"
465                            && let Some((70, units)) = it.next()
466                            && let Ok(units) = units.parse::<i32>()
467                        {
468                            out.insunits = Some(units);
469                            out.unit_mm = unit_mm(units);
470                        }
471                    }
472                }
473            }
474            "TABLES" => {
475                for record in records.iter().filter(|r| r.kind == "LAYER") {
476                    if let Some(layer) = record.text(2) {
477                        layer_linetype.insert(
478                            layer.to_ascii_uppercase(),
479                            record.text(6).unwrap_or("").to_string(),
480                        );
481                    }
482                }
483            }
484            _ => {}
485        }
486    }
487
488    let Some((_, records)) = sections.iter().find(|(name, _)| *name == "ENTITIES") else {
489        return Ok(out);
490    };
491    let mut k = 0;
492    while k < records.len() {
493        let record = &records[k];
494        k += 1;
495        let layer = record.text(8).unwrap_or("0").to_string();
496        let hidden = layer.eq_ignore_ascii_case("HIDDEN")
497            || record.text(6).is_some_and(dashed)
498            || layer_linetype
499                .get(&layer.to_ascii_uppercase())
500                .is_some_and(|l| dashed(l));
501        // The arbitrary axis: straight up reads as written, straight down
502        // mirrors x; anything tilted is not a drawing's plane.
503        let from_below = match record.real(230) {
504            None => false,
505            Some(z)
506                if (record.real(210).unwrap_or(0.0).abs()
507                    + record.real(220).unwrap_or(0.0).abs())
508                    <= 1e-12 =>
509            {
510                z < 0.0
511            }
512            Some(_) => ogeom_core::ogeom_bail!(
513                Construction,
514                "a {} entity is extruded along a tilted axis; only drawings in the XY plane \
515                 are read",
516                record.kind
517            ),
518        };
519        let flip = |p: Point2| {
520            if from_below {
521                Point2::new(-p.x, p.y)
522            } else {
523                p
524            }
525        };
526        let curve = match record.kind {
527            "LINE" => {
528                let (Some(a), Some(b)) = (record.point(10, 20), record.point(11, 21)) else {
529                    continue;
530                };
531                DxfCurve::Line {
532                    start: flip(a),
533                    end: flip(b),
534                }
535            }
536            "CIRCLE" => {
537                let (Some(centre), Some(radius)) = (record.point(10, 20), record.real(40)) else {
538                    continue;
539                };
540                DxfCurve::Circle {
541                    centre: flip(centre),
542                    radius,
543                }
544            }
545            "ARC" => {
546                let (Some(centre), Some(radius)) = (record.point(10, 20), record.real(40)) else {
547                    continue;
548                };
549                let start = record.real(50).unwrap_or(0.0).to_radians();
550                let end = record.real(51).unwrap_or(360.0).to_radians();
551                // Mirrored, the counter-clockwise run from start to end
552                // becomes the one from the mirror of end to that of start.
553                let (start_angle, end_angle) = if from_below {
554                    (core::f64::consts::PI - end, core::f64::consts::PI - start)
555                } else {
556                    (start, end)
557                };
558                DxfCurve::Arc {
559                    centre: flip(centre),
560                    radius,
561                    start_angle,
562                    end_angle,
563                }
564            }
565            "ELLIPSE" | "SPLINE" if from_below => ogeom_core::ogeom_bail!(
566                Construction,
567                "a {} seen from below (extrusion 0, 0, -1) is not read yet",
568                record.kind
569            ),
570            "ELLIPSE" => {
571                let (Some(centre), Some(major)) = (record.point(10, 20), record.point(11, 21))
572                else {
573                    continue;
574                };
575                DxfCurve::Ellipse {
576                    centre,
577                    major: Vector2::new(major.x, major.y),
578                    ratio: record.real(40).unwrap_or(1.0),
579                    start_param: record.real(41).unwrap_or(0.0),
580                    end_param: record.real(42).unwrap_or(core::f64::consts::TAU),
581                }
582            }
583            "SPLINE" => {
584                let flags = record.int(70).unwrap_or(0);
585                let control = record.points(10, 20);
586                let weights = record.reals(41);
587                let (control_points, knots) = if control.is_empty() {
588                    (record.points(11, 21), Vec::new())
589                } else {
590                    (control, record.reals(40))
591                };
592                DxfCurve::Spline {
593                    degree: usize::try_from(record.int(71).unwrap_or(3)).unwrap_or(3),
594                    knots,
595                    weights: (!weights.is_empty() && weights.len() == control_points.len())
596                        .then_some(weights),
597                    control_points,
598                    closed: flags & 1 != 0,
599                }
600            }
601            "LWPOLYLINE" => {
602                let mut vertices: Vec<(Point2, f64)> = Vec::new();
603                for (code, value) in &record.pairs {
604                    let Ok(v) = value.parse::<f64>() else {
605                        continue;
606                    };
607                    match code {
608                        10 => vertices.push((Point2::new(v, 0.0), 0.0)),
609                        20 => {
610                            if let Some(last) = vertices.last_mut() {
611                                last.0.y = v;
612                            }
613                        }
614                        42 => {
615                            if let Some(last) = vertices.last_mut() {
616                                last.1 = v;
617                            }
618                        }
619                        _ => {}
620                    }
621                }
622                polyline(vertices, record.int(70).unwrap_or(0), from_below)
623            }
624            "POLYLINE" => {
625                // The header's own 10/20 is the elevation; the vertices
626                // follow as records of their own up to SEQEND.
627                let mut vertices: Vec<(Point2, f64)> = Vec::new();
628                while k < records.len() && records[k].kind == "VERTEX" {
629                    let v = &records[k];
630                    k += 1;
631                    // A spline frame's control point is not on the curve.
632                    if v.int(70).unwrap_or(0) & 16 != 0 {
633                        continue;
634                    }
635                    if let Some(p) = v.point(10, 20) {
636                        vertices.push((p, v.real(42).unwrap_or(0.0)));
637                    }
638                }
639                if k < records.len() && records[k].kind == "SEQEND" {
640                    k += 1;
641                }
642                polyline(vertices, record.int(70).unwrap_or(0), from_below)
643            }
644            _ => continue,
645        };
646        out.entities.push(DxfEntity {
647            layer,
648            hidden,
649            curve,
650        });
651    }
652    Ok(out)
653}
654
655/// A polyline from its vertices and flags, mirrored in x when seen from
656/// below (a mirror turns every bulge the other way).
657fn polyline(vertices: Vec<(Point2, f64)>, flags: i64, from_below: bool) -> DxfCurve {
658    let vertices = if from_below {
659        vertices
660            .into_iter()
661            .map(|(p, b)| (Point2::new(-p.x, p.y), -b))
662            .collect()
663    } else {
664        vertices
665    };
666    DxfCurve::Polyline {
667        vertices,
668        closed: flags & 1 != 0,
669    }
670}