Skip to main content

dotloom_io/
dxf.rs

1//! ASCII DXF import and export.
2//!
3//! Import supports `LINE`, `LWPOLYLINE` (bulges), 2D `POLYLINE`/`VERTEX` (bulges),
4//! `CIRCLE`, `ARC`, `TEXT`, `MTEXT` (formatting stripped), `POINT` and the `LAYER`
5//! table, for AC1009 (R12) through AC1032 (2018). Units come from `$INSUNITS`
6//! (unitless files are read as millimetres and reported). Entities with an
7//! extrusion of `(0, 0, -1)` are mirrored correctly; other OCS orientations are
8//! reported. `INSERT`, `HATCH`, `SPLINE`, `ELLIPSE`, `DIMENSION` and every other
9//! entity are counted in the report — never dropped silently. Binary DXF is
10//! rejected. Text decoding: UTF-8 (AC1021+), otherwise `$DWGCODEPAGE`
11//! ANSI_1252/ANSI_1254 plus `\U+XXXX` escapes.
12//!
13//! Export writes R12 (AC1009) ASCII — the most widely readable variant — with
14//! `LINE`, `POLYLINE`/`VERTEX` (bulges), `CIRCLE`, `ARC`, `TEXT` and `POINT`, layers
15//! and `$INSUNITS = 4` (mm). Béziers are flattened and reported.
16
17use core::f64::consts::TAU;
18use std::{collections::BTreeMap, fmt::Write as _};
19
20use dotloom_document::{Color, LayerId, Style};
21use dotloom_engine::{Engine, NewEntity, document::builtin::is_builtin, scene::Primitive};
22use dotloom_geometry::{
23    Arc, Circle, FlattenTolerance, HAlign, Point, PointShape, Polyline, Segment, Shape, Text, VAlign, normalize_angle,
24    units::LengthUnit,
25};
26use thiserror::Error;
27
28use crate::report::{ConversionReport, LossKind, Recorder};
29
30/// DXF errors.
31#[derive(Debug, Clone, PartialEq, Eq, Error)]
32pub enum DxfError {
33    /// Binary DXF.
34    #[error("binary DXF is not supported; save as ASCII DXF")]
35    Binary,
36    /// Malformed group codes.
37    #[error("malformed DXF at line {line}: {msg}")]
38    Malformed {
39        /// Line number (1-based).
40        line: usize,
41        /// Message.
42        msg: String,
43    },
44    /// Input too large.
45    #[error("DXF input exceeds {0} bytes")]
46    TooLarge(usize),
47}
48
49/// Maximum DXF input size.
50pub const MAX_DXF_BYTES: usize = 256 << 20;
51
52/// Imported layers and entities.
53#[derive(Debug, Clone, PartialEq)]
54pub struct DxfImport {
55    /// Layer names with optional colors; index 0 is the DXF layer `0`.
56    pub layers: Vec<(String, Option<Color>)>,
57    /// `(layer index, entity)`.
58    pub entities: Vec<(usize, NewEntity)>,
59    /// Detected version (`$ACADVER`).
60    pub version: Option<String>,
61    /// Unit applied.
62    pub unit: LengthUnit,
63    /// Report.
64    pub report: ConversionReport,
65}
66
67const CP1254_HIGH: [(u8, char); 6] = [(0xD0, 'Ğ'), (0xDD, 'İ'), (0xDE, 'Ş'), (0xF0, 'ğ'), (0xFD, 'ı'), (0xFE, 'ş')];
68const CP1252_80: [char; 32] = [
69    '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’',
70    '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
71];
72
73fn decode_legacy(bytes: &[u8], turkish: bool) -> String {
74    bytes
75        .iter()
76        .map(|&b| {
77            if b < 0x80 {
78                b as char
79            } else if (0x80..0xA0).contains(&b) {
80                CP1252_80.get((b - 0x80) as usize).copied().unwrap_or('?')
81            } else if turkish && let Some((_, c)) = CP1254_HIGH.iter().find(|(x, _)| *x == b) {
82                *c
83            } else {
84                char::from_u32(u32::from(b)).unwrap_or('?')
85            }
86        })
87        .collect()
88}
89
90/// Replace `\U+XXXX` escapes.
91fn unescape(s: &str) -> String {
92    let mut out = String::with_capacity(s.len());
93    let mut rest = s;
94    while let Some(i) = rest.find("\\U+") {
95        out.push_str(&rest[..i]);
96        let hex = rest.get(i + 3..i + 7).unwrap_or("");
97        match u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
98            Some(c) if hex.len() == 4 => {
99                out.push(c);
100                rest = &rest[i + 7..];
101            }
102            _ => {
103                out.push_str("\\U+");
104                rest = &rest[i + 3..];
105            }
106        }
107    }
108    out.push_str(rest);
109    out
110}
111
112/// Strip MTEXT formatting codes (`\P` → newline, `{\f...;}` groups, `\~`).
113fn strip_mtext(s: &str) -> String {
114    let mut out = String::new();
115    let mut chars = s.chars().peekable();
116    while let Some(c) = chars.next() {
117        match c {
118            '\\' => match chars.next() {
119                Some('P' | 'n') => out.push('\n'),
120                Some('~') => out.push(' '),
121                Some('\\') => out.push('\\'),
122                Some('{') => out.push('{'),
123                Some('}') => out.push('}'),
124                Some(_) => {
125                    // Skip a formatting code up to its terminating ';' (if any on this run).
126                    for d in chars.by_ref() {
127                        if d == ';' {
128                            break;
129                        }
130                    }
131                }
132                None => {}
133            },
134            '{' | '}' => {}
135            c => out.push(c),
136        }
137    }
138    out
139}
140
141struct Pairs<'a> {
142    lines: Vec<&'a str>,
143    i: usize,
144}
145
146impl<'a> Pairs<'a> {
147    fn next(&mut self) -> Option<Result<(i32, &'a str, usize), DxfError>> {
148        let code = *self.lines.get(self.i)?;
149        // A trailing blank line (or an empty file) ends the stream.
150        if code.trim().is_empty() && self.i + 1 >= self.lines.len() {
151            return None;
152        }
153        let value = self.lines.get(self.i + 1).copied().unwrap_or("");
154        let line = self.i + 1;
155        self.i += 2;
156        Some(match code.trim().parse::<i32>() {
157            Ok(c) => Ok((c, value.trim_end_matches('\r'), line)),
158            Err(_) => Err(DxfError::Malformed { line, msg: format!("expected a group code, found `{}`", code.trim()) }),
159        })
160    }
161}
162
163#[derive(Default)]
164struct Ent {
165    kind: String,
166    codes: Vec<(i32, String)>,
167}
168
169impl Ent {
170    fn f(&self, code: i32) -> Option<f64> {
171        self.codes
172            .iter()
173            .find(|(c, _)| *c == code)
174            .and_then(|(_, v)| v.trim().parse().ok())
175            .filter(|v: &f64| v.is_finite())
176    }
177    fn s(&self, code: i32) -> Option<&str> {
178        self.codes.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
179    }
180    fn i(&self, code: i32) -> Option<i64> {
181        self.codes.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.trim().parse().ok())
182    }
183}
184
185fn aci(index: i64) -> Option<Color> {
186    Some(match index {
187        1 => Color::rgba(255, 0, 0, 255),
188        2 => Color::rgba(255, 255, 0, 255),
189        3 => Color::rgba(0, 255, 0, 255),
190        4 => Color::rgba(0, 255, 255, 255),
191        5 => Color::rgba(0, 0, 255, 255),
192        6 => Color::rgba(255, 0, 255, 255),
193        8 => Color::rgba(128, 128, 128, 255),
194        9 => Color::rgba(192, 192, 192, 255),
195        _ => return None,
196    })
197}
198
199fn insunits(v: i64) -> Option<LengthUnit> {
200    Some(match v {
201        1 => LengthUnit::Inch,
202        2 => LengthUnit::Foot,
203        4 => LengthUnit::Millimetre,
204        5 => LengthUnit::Centimetre,
205        6 => LengthUnit::Metre,
206        _ => return None,
207    })
208}
209
210/// Import ASCII DXF bytes.
211pub fn import_dxf(bytes: &[u8]) -> Result<DxfImport, DxfError> {
212    if bytes.len() > MAX_DXF_BYTES {
213        return Err(DxfError::TooLarge(MAX_DXF_BYTES));
214    }
215    if bytes.starts_with(b"AutoCAD Binary DXF") {
216        return Err(DxfError::Binary);
217    }
218    let raw = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
219    let text = match core::str::from_utf8(raw) {
220        Ok(s) => s.to_owned(),
221        Err(_) => {
222            let turkish = raw.windows(10).any(|w| w == b"ANSI_1254\r" || w == b"ANSI_1254\n")
223                || raw.windows(9).any(|w| w == b"ANSI_1254");
224            decode_legacy(raw, turkish)
225        }
226    };
227    let mut pairs = Pairs { lines: text.split('\n').collect(), i: 0 };
228    let mut rec = Recorder::default();
229    let mut layers: Vec<(String, Option<Color>)> = vec![("0".into(), None)];
230    let mut layer_index: BTreeMap<String, usize> = [("0".to_owned(), 0)].into_iter().collect();
231    let mut version = None;
232    let mut unit_code: Option<i64> = None;
233    let mut section = String::new();
234    let mut ents: Vec<Ent> = Vec::new();
235    let mut cur: Option<Ent> = None;
236    let mut header_var = String::new();
237    let mut in_layer_table = false;
238    let mut block_count = 0usize;
239    let mut steps = 0usize;
240    while let Some(p) = pairs.next() {
241        let (code, value, _) = p?;
242        steps += 1;
243        if steps > 50_000_000 {
244            return Err(DxfError::TooLarge(MAX_DXF_BYTES));
245        }
246        if code == 0 {
247            if let Some(e) = cur.take() {
248                match section.as_str() {
249                    "ENTITIES" => ents.push(e),
250                    "TABLES" if in_layer_table && e.kind == "LAYER" => {
251                        if let Some(name) = e.s(2) {
252                            let color = e.i(62).and_then(|c| aci(c.abs()));
253                            if !layer_index.contains_key(name) {
254                                layer_index.insert(name.to_owned(), layers.len());
255                                layers.push((unescape(name), color));
256                            }
257                        }
258                    }
259                    _ => {}
260                }
261            }
262            match value {
263                "SECTION" => {
264                    // The next pair (2, name) names the section.
265                    if let Some(Ok((2, name, _))) = pairs.next() {
266                        section = name.to_owned();
267                    }
268                }
269                "ENDSEC" => section.clear(),
270                "TABLE" => {
271                    if let Some(Ok((2, name, _))) = pairs.next() {
272                        in_layer_table = name == "LAYER";
273                    }
274                }
275                "ENDTAB" => in_layer_table = false,
276                "EOF" => break,
277                "BLOCK" if section == "BLOCKS" => block_count += 1,
278                other => {
279                    if section == "ENTITIES" || section == "TABLES" {
280                        cur = Some(Ent { kind: other.to_owned(), codes: Vec::new() });
281                    }
282                }
283            }
284            continue;
285        }
286        if section == "HEADER" {
287            if code == 9 {
288                header_var = value.to_owned();
289            } else if header_var == "$ACADVER" && code == 1 {
290                version = Some(value.trim().to_owned());
291            } else if header_var == "$INSUNITS" && code == 70 {
292                unit_code = value.trim().parse().ok();
293            }
294            continue;
295        }
296        if let Some(e) = cur.as_mut() {
297            if e.codes.len() > 10_000_000 {
298                return Err(DxfError::TooLarge(MAX_DXF_BYTES));
299            }
300            e.codes.push((code, value.to_owned()));
301        }
302    }
303    if let Some(e) = cur.take()
304        && section == "ENTITIES"
305    {
306        ents.push(e);
307    }
308    if block_count > 0 {
309        rec.notes.push(format!("{block_count} block definition(s) not imported"));
310    }
311    let unit = match unit_code {
312        Some(c) => insunits(c).unwrap_or_else(|| {
313            rec.add(LossKind::Units, format!("$INSUNITS {c} read as millimetres"));
314            LengthUnit::Millimetre
315        }),
316        None => {
317            rec.add(LossKind::Units, "no $INSUNITS: read as millimetres");
318            LengthUnit::Millimetre
319        }
320    };
321    let k = unit.mm_per_unit();
322    rec.notes.push(format!("version {}, unit {}", version.as_deref().unwrap_or("unknown"), unit.symbol()));
323    let mut out = Vec::new();
324    let mut i = 0;
325    while i < ents.len() {
326        let e = &ents[i];
327        let layer = e.s(8).and_then(|n| layer_index.get(n).copied()).unwrap_or(0);
328        let color = e.i(62).and_then(aci);
329        let mirrored = e.f(230).is_some_and(|z| z < 0.0);
330        if e.f(230).is_some_and(|z| z.abs() < 0.999_999)
331            || e.f(210).is_some_and(|x| x.abs() > 1e-9)
332            || e.f(220).is_some_and(|y| y.abs() > 1e-9)
333        {
334            rec.add(LossKind::Unsupported, format!("{} with a non-planar extrusion", e.kind));
335            i += 1;
336            continue;
337        }
338        // OCS (0,0,-1): x is mirrored.
339        let pt = |x: f64, y: f64| Point::new(if mirrored { -x * k } else { x * k }, y * k);
340        let mut push = |shape: Shape, rec: &mut Recorder| {
341            if shape.validate().is_ok() {
342                rec.entities += 1;
343                out.push((
344                    layer,
345                    NewEntity {
346                        geometry: Some(shape),
347                        style: color.map(|c| Style { stroke: Some(c), ..Style::default() }),
348                        ..NewEntity::default()
349                    },
350                ));
351            } else {
352                rec.add(LossKind::Unsupported, format!("degenerate {}", e.kind));
353            }
354        };
355        match e.kind.as_str() {
356            "LINE" => push(
357                Shape::Line(Segment::new(
358                    pt(e.f(10).unwrap_or(0.0), e.f(20).unwrap_or(0.0)),
359                    pt(e.f(11).unwrap_or(0.0), e.f(21).unwrap_or(0.0)),
360                )),
361                &mut rec,
362            ),
363            "POINT" => {
364                push(Shape::Point(PointShape { at: pt(e.f(10).unwrap_or(0.0), e.f(20).unwrap_or(0.0)) }), &mut rec)
365            }
366            "CIRCLE" => push(
367                Shape::Circle(Circle {
368                    center: pt(e.f(10).unwrap_or(0.0), e.f(20).unwrap_or(0.0)),
369                    radius: e.f(40).unwrap_or(0.0) * k,
370                }),
371                &mut rec,
372            ),
373            "ARC" => {
374                let (a0, a1) = (e.f(50).unwrap_or(0.0).to_radians(), e.f(51).unwrap_or(0.0).to_radians());
375                let mut sweep = normalize_angle(a1 - a0);
376                if sweep == 0.0 {
377                    sweep = TAU;
378                }
379                let c = pt(e.f(10).unwrap_or(0.0), e.f(20).unwrap_or(0.0));
380                // Mirroring flips the direction: start π−a0, sweep negative.
381                let (start, sweep) = if mirrored { (core::f64::consts::PI - a0, -sweep) } else { (a0, sweep) };
382                match Arc::new(c, e.f(40).unwrap_or(0.0) * k, start, sweep) {
383                    Ok(a) => push(Shape::Arc(a), &mut rec),
384                    Err(_) => rec.add(LossKind::Unsupported, "degenerate ARC"),
385                }
386            }
387            "LWPOLYLINE" => {
388                let closed = e.i(70).is_some_and(|f| f & 1 != 0);
389                let mut pts = Vec::new();
390                let mut bulges = Vec::new();
391                let mut x = None;
392                for (c, v) in &e.codes {
393                    match c {
394                        10 => x = v.trim().parse::<f64>().ok(),
395                        20 => {
396                            if let (Some(xv), Ok(yv)) = (x.take(), v.trim().parse::<f64>()) {
397                                pts.push(pt(xv, yv));
398                                bulges.push(0.0);
399                            }
400                        }
401                        42 => {
402                            if let (Some(b), Ok(bv)) = (bulges.last_mut(), v.trim().parse::<f64>()) {
403                                *b = if mirrored { -bv } else { bv };
404                            }
405                        }
406                        _ => {}
407                    }
408                }
409                if !closed {
410                    bulges.pop();
411                }
412                if bulges.iter().all(|b| *b == 0.0) {
413                    bulges.clear();
414                }
415                if e.codes
416                    .iter()
417                    .any(|(c, v)| (*c == 40 || *c == 41 || *c == 43) && v.trim().parse::<f64>().is_ok_and(|w| w != 0.0))
418                {
419                    rec.add(LossKind::Style, "polyline width");
420                }
421                push(Shape::Polyline(Polyline { points: pts, bulges, closed }), &mut rec);
422            }
423            "POLYLINE" => {
424                let flags = e.i(70).unwrap_or(0);
425                let closed = flags & 1 != 0;
426                let mut pts = Vec::new();
427                let mut bulges = Vec::new();
428                let mut j = i + 1;
429                while let Some(v) = ents.get(j) {
430                    if v.kind != "VERTEX" {
431                        break;
432                    }
433                    pts.push(pt(v.f(10).unwrap_or(0.0), v.f(20).unwrap_or(0.0)));
434                    bulges.push(v.f(42).map_or(0.0, |b| if mirrored { -b } else { b }));
435                    j += 1;
436                }
437                if ents.get(j).is_some_and(|s| s.kind == "SEQEND") {
438                    j += 1;
439                }
440                i = j;
441                if flags & (8 | 16 | 64) != 0 {
442                    rec.add(LossKind::Unsupported, "3D POLYLINE / mesh");
443                    continue;
444                }
445                if !closed {
446                    bulges.pop();
447                }
448                if bulges.iter().all(|b| *b == 0.0) {
449                    bulges.clear();
450                }
451                push(Shape::Polyline(Polyline { points: pts, bulges, closed }), &mut rec);
452                continue;
453            }
454            "TEXT" | "MTEXT" => {
455                let is_m = e.kind == "MTEXT";
456                let mut content = if is_m {
457                    let mut s = String::new();
458                    for (c, v) in &e.codes {
459                        if *c == 3 || *c == 1 {
460                            s.push_str(v);
461                        }
462                    }
463                    rec.add(LossKind::Text, "MTEXT formatting");
464                    strip_mtext(&unescape(&s))
465                } else {
466                    unescape(e.s(1).unwrap_or(""))
467                };
468                content = content.replace("%%d", "°").replace("%%c", "Ø").replace("%%p", "±");
469                let (h_just, v_just) = (e.i(72).unwrap_or(0), e.i(73).unwrap_or(0));
470                let use_align = !is_m && (h_just != 0 || v_just != 0) && e.f(11).is_some();
471                let pos = if use_align {
472                    pt(e.f(11).unwrap_or(0.0), e.f(21).unwrap_or(0.0))
473                } else {
474                    pt(e.f(10).unwrap_or(0.0), e.f(20).unwrap_or(0.0))
475                };
476                let (halign, valign) = if is_m {
477                    let a = e.i(71).unwrap_or(1);
478                    (
479                        match (a - 1) % 3 {
480                            1 => HAlign::Center,
481                            2 => HAlign::Right,
482                            _ => HAlign::Left,
483                        },
484                        match (a - 1) / 3 {
485                            0 => VAlign::Top,
486                            1 => VAlign::Middle,
487                            _ => VAlign::Bottom,
488                        },
489                    )
490                } else {
491                    (
492                        match h_just {
493                            1 | 4 => HAlign::Center,
494                            2 => HAlign::Right,
495                            _ => HAlign::Left,
496                        },
497                        match v_just {
498                            1 => VAlign::Bottom,
499                            2 => VAlign::Middle,
500                            3 => VAlign::Top,
501                            _ => VAlign::Baseline,
502                        },
503                    )
504                };
505                let height = e.f(40).unwrap_or(1.0) * k;
506                let rot = e.f(50).unwrap_or(0.0).to_radians();
507                if content.trim().is_empty() {
508                    i += 1;
509                    continue;
510                }
511                push(
512                    Shape::Text(Text {
513                        position: pos,
514                        content,
515                        height,
516                        rotation: if mirrored { core::f64::consts::PI - rot } else { rot },
517                        halign,
518                        valign,
519                    }),
520                    &mut rec,
521                );
522            }
523            "VERTEX" | "SEQEND" => {}
524            other => rec.add(LossKind::Unsupported, other.to_owned()),
525        }
526        i += 1;
527    }
528    Ok(DxfImport { layers, entities: out, version, unit, report: rec.finish() })
529}
530
531// ---------------------------------------------------------------------------
532// Export
533
534fn g(out: &mut String, code: i32, value: impl core::fmt::Display) {
535    let _ = write!(out, "{code:>3}\r\n{value}\r\n");
536}
537
538fn f(v: f64) -> String {
539    let s = format!("{v}");
540    if s.contains('.') || s.contains('e') { s } else { format!("{s}.0") }
541}
542
543fn dxf_text(s: &str, rec: &mut Recorder) -> String {
544    // R12 files are ASCII/ANSI: non-ASCII characters are written as \U+XXXX, which
545    // has four hex digits; R12 TEXT has a single line. Every change is reported.
546    // (Joined lines are reported where the entity is written.)
547    let mut o = String::with_capacity(s.len());
548    let (mut replaced, mut removed) = (false, false);
549    for c in s.chars() {
550        if c.is_ascii() && !c.is_ascii_control() {
551            o.push(c);
552        } else if c == '\n' {
553            o.push(' ');
554        } else if c.is_control() {
555            removed = true;
556        } else if (c as u32) <= 0xFFFF {
557            let _ = write!(o, "\\U+{:04X}", c as u32);
558        } else {
559            o.push('?');
560            replaced = true;
561        }
562    }
563    if replaced {
564        rec.add(LossKind::Text, "characters outside the Basic Multilingual Plane written as '?'");
565    }
566    if removed {
567        rec.add(LossKind::Text, "control characters removed from text");
568    }
569    o
570}
571
572fn layer_name(s: &str) -> String {
573    let n: String = s
574        .chars()
575        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_uppercase() } else { '_' })
576        .take(255)
577        .collect();
578    if n.is_empty() { "0".into() } else { n }
579}
580
581fn poly(out: &mut String, layer: &str, pts: &[Point], bulges: &[f64], closed: bool) {
582    g(out, 0, "POLYLINE");
583    g(out, 8, layer);
584    g(out, 66, 1);
585    g(out, 10, "0.0");
586    g(out, 20, "0.0");
587    g(out, 30, "0.0");
588    g(out, 70, i32::from(closed));
589    for (i, p) in pts.iter().enumerate() {
590        g(out, 0, "VERTEX");
591        g(out, 8, layer);
592        g(out, 10, f(p.x));
593        g(out, 20, f(p.y));
594        g(out, 30, "0.0");
595        if let Some(b) = bulges.get(i)
596            && *b != 0.0
597        {
598            g(out, 42, f(*b));
599        }
600    }
601    g(out, 0, "SEQEND");
602    g(out, 8, layer);
603}
604
605fn shape_dxf(out: &mut String, layer: &str, s: &Shape, rec: &mut Recorder) {
606    match s {
607        Shape::Point(p) => {
608            g(out, 0, "POINT");
609            g(out, 8, layer);
610            g(out, 10, f(p.at.x));
611            g(out, 20, f(p.at.y));
612            g(out, 30, "0.0");
613        }
614        Shape::Line(l) => {
615            g(out, 0, "LINE");
616            g(out, 8, layer);
617            g(out, 10, f(l.a.x));
618            g(out, 20, f(l.a.y));
619            g(out, 30, "0.0");
620            g(out, 11, f(l.b.x));
621            g(out, 21, f(l.b.y));
622            g(out, 31, "0.0");
623        }
624        Shape::Circle(c) => {
625            g(out, 0, "CIRCLE");
626            g(out, 8, layer);
627            g(out, 10, f(c.center.x));
628            g(out, 20, f(c.center.y));
629            g(out, 30, "0.0");
630            g(out, 40, f(c.radius));
631        }
632        Shape::Arc(a) => {
633            // DXF arcs are counter-clockwise from 50 to 51 (degrees).
634            let a = if a.sweep < 0.0 { a.reversed() } else { *a };
635            g(out, 0, "ARC");
636            g(out, 8, layer);
637            g(out, 10, f(a.center.x));
638            g(out, 20, f(a.center.y));
639            g(out, 30, "0.0");
640            g(out, 40, f(a.radius));
641            g(out, 50, f(normalize_angle(a.start).to_degrees()));
642            g(out, 51, f(normalize_angle(a.end_angle()).to_degrees()));
643        }
644        Shape::Polyline(p) => poly(out, layer, &p.points, &p.bulges, p.closed),
645        Shape::Rect(r) => poly(out, layer, &r.corners(), &[], true),
646        Shape::Polygon(pg) => {
647            poly(out, layer, &pg.outer, &[], true);
648            for h in &pg.holes {
649                rec.add(LossKind::Approximated, "polygon hole written as separate polyline");
650                poly(out, layer, h, &[], true);
651            }
652        }
653        Shape::Path(_) => {
654            rec.add(LossKind::Approximated, "Bézier path flattened");
655            let tol = FlattenTolerance((s.bbox().size().length() * 1e-4).max(1e-6));
656            for fp in s.flatten(tol) {
657                poly(out, layer, &fp.points, &[], fp.closed);
658            }
659        }
660        Shape::Text(t) => {
661            g(out, 0, "TEXT");
662            g(out, 8, layer);
663            g(out, 10, f(t.position.x));
664            g(out, 20, f(t.position.y));
665            g(out, 30, "0.0");
666            g(out, 40, f(t.height));
667            g(out, 1, dxf_text(&t.content, rec));
668            if t.rotation != 0.0 {
669                g(out, 50, f(t.rotation.to_degrees()));
670            }
671            let h = match t.halign {
672                HAlign::Left => 0,
673                HAlign::Center => 1,
674                HAlign::Right => 2,
675            };
676            let v = match t.valign {
677                VAlign::Baseline => 0,
678                VAlign::Bottom => 1,
679                VAlign::Middle => 2,
680                VAlign::Top => 3,
681            };
682            if h != 0 || v != 0 {
683                g(out, 72, h);
684                g(out, 73, v);
685                g(out, 11, f(t.position.x));
686                g(out, 21, f(t.position.y));
687                g(out, 31, "0.0");
688            }
689            if t.content.contains('\n') {
690                rec.add(LossKind::Text, "multi-line text joined");
691            }
692        }
693    }
694}
695
696/// Export the engine's committed document as R12 ASCII DXF (mm).
697pub fn export_dxf(engine: &mut Engine) -> (String, ConversionReport) {
698    let mut rec = Recorder::default();
699    let scene = engine.full_scene();
700    let doc = engine.document();
701    for _ in 0..doc.constraint_count() {
702        rec.add(LossKind::Constraints, "constraint");
703    }
704    let layers: Vec<(LayerId, String)> = doc.layers().iter().map(|l| (l.id, layer_name(&l.name))).collect();
705    let mut out = String::new();
706    g(&mut out, 999, "Dotloom DXF export (R12, millimetres)");
707    g(&mut out, 0, "SECTION");
708    g(&mut out, 2, "HEADER");
709    g(&mut out, 9, "$ACADVER");
710    g(&mut out, 1, "AC1009");
711    g(&mut out, 9, "$INSUNITS");
712    g(&mut out, 70, 4);
713    g(&mut out, 0, "ENDSEC");
714    g(&mut out, 0, "SECTION");
715    g(&mut out, 2, "TABLES");
716    g(&mut out, 0, "TABLE");
717    g(&mut out, 2, "LAYER");
718    g(&mut out, 70, layers.len());
719    for (_, name) in &layers {
720        g(&mut out, 0, "LAYER");
721        g(&mut out, 2, name);
722        g(&mut out, 70, 0);
723        g(&mut out, 62, 7);
724        g(&mut out, 6, "CONTINUOUS");
725    }
726    g(&mut out, 0, "ENDTAB");
727    g(&mut out, 0, "ENDSEC");
728    g(&mut out, 0, "SECTION");
729    g(&mut out, 2, "ENTITIES");
730    let order: Vec<u64> = scene.order.clone().unwrap_or_default();
731    for id in order {
732        let Some(item) = scene.upserts.iter().find(|i| i.id == id) else { continue };
733        let Some(e) = doc.entity(dotloom_document::EntityId(id)) else { continue };
734        let lname = layers.get(item.layer as usize).map_or("0", |(_, n)| n.as_str()).to_owned();
735        rec.entities += 1;
736        if !is_builtin(&e.type_id) {
737            rec.add(LossKind::PluginGeometry, e.type_id.to_string());
738        } else if e.type_id.as_str() == dotloom_document::builtin::types::DIMENSION {
739            rec.add(LossKind::Dimensions, "dimension");
740        }
741        for prim in &item.prims {
742            match prim {
743                Primitive::Shape { shape, fill, .. } => {
744                    if fill.is_some() {
745                        rec.add(LossKind::Style, "fill");
746                    }
747                    shape_dxf(&mut out, &lname, shape, &mut rec);
748                }
749                Primitive::Text { text, .. } => shape_dxf(&mut out, &lname, &Shape::Text(text.clone()), &mut rec),
750                Primitive::Arrow { tip, direction, size, .. } => {
751                    let back = *tip - *direction * *size;
752                    let n = direction.perp() * (*size * 0.3);
753                    poly(&mut out, &lname, &[*tip, back + n, back - n], &[], true);
754                }
755            }
756        }
757    }
758    g(&mut out, 0, "ENDSEC");
759    g(&mut out, 0, "EOF");
760    rec.notes.push("written as AC1009 (R12) ASCII, millimetres".into());
761    (out, rec.finish())
762}