1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
32pub enum DxfError {
33 #[error("binary DXF is not supported; save as ASCII DXF")]
35 Binary,
36 #[error("malformed DXF at line {line}: {msg}")]
38 Malformed {
39 line: usize,
41 msg: String,
43 },
44 #[error("DXF input exceeds {0} bytes")]
46 TooLarge(usize),
47}
48
49pub const MAX_DXF_BYTES: usize = 256 << 20;
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct DxfImport {
55 pub layers: Vec<(String, Option<Color>)>,
57 pub entities: Vec<(usize, NewEntity)>,
59 pub version: Option<String>,
61 pub unit: LengthUnit,
63 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
90fn 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
112fn 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 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 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
210pub 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 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 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 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
531fn 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 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 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
696pub 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}