Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
316 lines
12 KiB
Rust
316 lines
12 KiB
Rust
//! ASCII DXF (the public AutoCAD DXF reference): the ENTITIES section's LINE, CIRCLE, ARC,
|
|
//! LWPOLYLINE, POLYLINE/VERTEX, POINT, ELLIPSE, SPLINE, TEXT and MTEXT, scaled to millimetres by
|
|
//! `$INSUNITS`. Blocks are not expanded. The writer emits an R12-style file of lines, circles,
|
|
//! arcs and points (free-form curves as polylines).
|
|
|
|
use solvecraft_geom::Vec2;
|
|
|
|
use crate::sketch2d::{Geom2, MAX_DRAWING_BYTES, MAX_ENTITIES, bulge_arc};
|
|
use crate::{IoError, Result};
|
|
|
|
/// Group code / value pairs.
|
|
fn pairs(text: &str) -> Vec<(i32, String)> {
|
|
let mut out = Vec::new();
|
|
let mut lines = text.lines();
|
|
while let (Some(c), Some(v)) = (lines.next(), lines.next()) {
|
|
let Ok(code) = c.trim().parse::<i32>() else { continue };
|
|
out.push((code, v.trim().to_string()));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn units_scale(p: &[(i32, String)]) -> f64 {
|
|
// $INSUNITS: 1 in, 2 ft, 4 mm, 5 cm, 6 m, 0 unitless (taken as mm).
|
|
let i = p.iter().position(|(c, v)| *c == 9 && v == "$INSUNITS");
|
|
match i.and_then(|i| p.get(i + 1)).and_then(|(_, v)| v.parse::<i32>().ok()) {
|
|
Some(1) => 25.4,
|
|
Some(2) => 304.8,
|
|
Some(5) => 10.0,
|
|
Some(6) => 1000.0,
|
|
_ => 1.0,
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Ent {
|
|
kind: String,
|
|
f: Vec<(i32, f64)>,
|
|
s: Vec<(i32, String)>,
|
|
}
|
|
|
|
impl Ent {
|
|
fn get(&self, code: i32) -> Option<f64> {
|
|
self.f.iter().find(|(c, _)| *c == code).map(|(_, v)| *v)
|
|
}
|
|
fn all(&self, code: i32) -> Vec<f64> {
|
|
self.f.iter().filter(|(c, _)| *c == code).map(|(_, v)| *v).collect()
|
|
}
|
|
fn text(&self, code: i32) -> Option<&str> {
|
|
self.s.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str())
|
|
}
|
|
}
|
|
|
|
/// Read the entities of an ASCII DXF file.
|
|
pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
|
|
if text.len() > MAX_DRAWING_BYTES {
|
|
return Err(IoError::Invalid("the DXF file is too large".into()));
|
|
}
|
|
if text.starts_with("AutoCAD Binary DXF") {
|
|
return Err(IoError::Invalid("binary DXF is not supported; save as ASCII DXF".into()));
|
|
}
|
|
let p = pairs(text);
|
|
let k = units_scale(&p);
|
|
// Entities of the ENTITIES section.
|
|
let start = p.iter().position(|(c, v)| *c == 2 && v == "ENTITIES").ok_or_else(|| IoError::Invalid("no ENTITIES section".into()))?;
|
|
let mut ents: Vec<Ent> = Vec::new();
|
|
for (c, v) in p.iter().skip(start + 1) {
|
|
if *c == 0 {
|
|
if v == "ENDSEC" || v == "EOF" {
|
|
break;
|
|
}
|
|
ents.push(Ent { kind: v.clone(), ..Default::default() });
|
|
if ents.len() > MAX_ENTITIES {
|
|
return Err(IoError::Invalid("too many entities".into()));
|
|
}
|
|
continue;
|
|
}
|
|
let Some(e) = ents.last_mut() else { continue };
|
|
let numeric = matches!(*c, 10..=59 | 60..=99 | 140..=147 | 210..=239);
|
|
match v.parse::<f64>() {
|
|
Ok(x) if numeric && x.is_finite() && x.abs() < 1e12 => e.f.push((*c, x)),
|
|
_ => e.s.push((*c, v.clone())),
|
|
}
|
|
}
|
|
let pt = |x: f64, y: f64| Vec2::new(x * k, y * k);
|
|
let mut out = Vec::new();
|
|
let mut i = 0;
|
|
while i < ents.len() {
|
|
let Some(e) = ents.get(i) else { break };
|
|
i += 1;
|
|
match e.kind.as_str() {
|
|
"LINE" => {
|
|
if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (e.get(10), e.get(20), e.get(11), e.get(21)) {
|
|
out.push(Geom2::Line(pt(x0, y0), pt(x1, y1)));
|
|
}
|
|
}
|
|
"POINT" => {
|
|
if let (Some(x), Some(y)) = (e.get(10), e.get(20)) {
|
|
out.push(Geom2::Point(pt(x, y)));
|
|
}
|
|
}
|
|
"CIRCLE" => {
|
|
if let (Some(x), Some(y), Some(r)) = (e.get(10), e.get(20), e.get(40)) {
|
|
out.push(Geom2::Circle(pt(x, y), r * k));
|
|
}
|
|
}
|
|
"ARC" => {
|
|
if let (Some(x), Some(y), Some(r), Some(a0), Some(a1)) = (e.get(10), e.get(20), e.get(40), e.get(50), e.get(51)) {
|
|
let c = pt(x, y);
|
|
let at = |deg: f64| c + Vec2::from_angle(deg.to_radians()) * (r * k);
|
|
out.push(Geom2::Arc { c, a: at(a0), b: at(a1) });
|
|
}
|
|
}
|
|
"LWPOLYLINE" => {
|
|
let ys = e.all(20);
|
|
// Bulges follow their vertex: walk the pairs in order.
|
|
let mut verts: Vec<(Vec2, f64)> = Vec::new();
|
|
let mut xi = 0;
|
|
for (c, v) in &e.f {
|
|
match *c {
|
|
10 => {
|
|
if let Some(y) = ys.get(xi) {
|
|
verts.push((pt(*v, *y), 0.0));
|
|
}
|
|
xi += 1;
|
|
}
|
|
42 => {
|
|
if let Some(last) = verts.last_mut() {
|
|
last.1 = *v;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let closed = e.get(70).is_some_and(|f| (f as i64) & 1 == 1);
|
|
polyline(&verts, closed, &mut out);
|
|
}
|
|
"POLYLINE" => {
|
|
let closed = e.get(70).is_some_and(|f| (f as i64) & 1 == 1);
|
|
let mut verts = Vec::new();
|
|
while let Some(v) = ents.get(i) {
|
|
if v.kind != "VERTEX" {
|
|
break;
|
|
}
|
|
if let (Some(x), Some(y)) = (v.get(10), v.get(20)) {
|
|
verts.push((pt(x, y), v.get(42).unwrap_or(0.0)));
|
|
}
|
|
i += 1;
|
|
}
|
|
if ents.get(i).is_some_and(|x| x.kind == "SEQEND") {
|
|
i += 1;
|
|
}
|
|
polyline(&verts, closed, &mut out);
|
|
}
|
|
"ELLIPSE" => {
|
|
if let (Some(x), Some(y), Some(mx), Some(my), Some(ratio)) = (e.get(10), e.get(20), e.get(11), e.get(21), e.get(40)) {
|
|
let c = pt(x, y);
|
|
let major = c + Vec2::new(mx, my) * k;
|
|
let a = Vec2::new(mx, my).len() * k;
|
|
let (t0, t1) = (e.get(41).unwrap_or(0.0), e.get(42).unwrap_or(std::f64::consts::TAU));
|
|
if (t1 - t0 - std::f64::consts::TAU).abs() < 1e-9 || (t1 - t0).abs() < 1e-12 {
|
|
out.push(Geom2::Ellipse { c, major, minor: a * ratio });
|
|
} else {
|
|
// Partial ellipse: a fit spline through samples.
|
|
let u = (major - c) / a.max(1e-12);
|
|
let v = u.perp() * (a * ratio);
|
|
let mut sweep = t1 - t0;
|
|
while sweep <= 0.0 {
|
|
sweep += std::f64::consts::TAU;
|
|
}
|
|
let n = 16;
|
|
let pts = (0..=n)
|
|
.map(|j| {
|
|
let t = t0 + sweep * j as f64 / n as f64;
|
|
c + u * (a * t.cos()) + v * t.sin()
|
|
})
|
|
.collect();
|
|
out.push(Geom2::Spline { pts, control: false, degree: 3 });
|
|
}
|
|
}
|
|
}
|
|
"SPLINE" => {
|
|
let degree = e.get(71).unwrap_or(3.0).clamp(1.0, 7.0) as u8;
|
|
let (fx, fy) = (e.all(11), e.all(21));
|
|
let (cx, cy) = (e.all(10), e.all(20));
|
|
if fx.len() >= 2 && fx.len() == fy.len() {
|
|
out.push(Geom2::Spline { pts: fx.iter().zip(&fy).map(|(x, y)| pt(*x, *y)).collect(), control: false, degree: 3 });
|
|
} else if cx.len() >= 2 && cx.len() == cy.len() {
|
|
out.push(Geom2::Spline { pts: cx.iter().zip(&cy).map(|(x, y)| pt(*x, *y)).collect(), control: true, degree });
|
|
}
|
|
}
|
|
"TEXT" | "MTEXT" => {
|
|
if let (Some(x), Some(y), Some(h), Some(t)) = (e.get(10), e.get(20), e.get(40), e.text(1)) {
|
|
// MTEXT paragraph codes: keep the plain text.
|
|
let t = t.replace("\\P", "\n");
|
|
out.push(Geom2::Text { text: t, at: pt(x, y), height: h * k, angle: e.get(50).unwrap_or(0.0).to_radians() });
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn polyline(verts: &[(Vec2, f64)], closed: bool, out: &mut Vec<Geom2>) {
|
|
let n = verts.len();
|
|
let segs = if closed { n } else { n.saturating_sub(1) };
|
|
for j in 0..segs {
|
|
let (Some((p0, b)), Some((p1, _))) = (verts.get(j), verts.get((j + 1) % n)) else { continue };
|
|
if p0.dist(*p1) < 1e-12 {
|
|
continue;
|
|
}
|
|
if let Some(g) = bulge_arc(*p0, *p1, *b) {
|
|
out.push(g);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write 2D geometry (millimetres) as an R12-style ASCII DXF.
|
|
pub fn write_dxf(geom: &[Geom2]) -> String {
|
|
let mut s = String::new();
|
|
let mut w = |c: i32, v: &str| {
|
|
s.push_str(&format!("{c}\n{v}\n"));
|
|
};
|
|
w(0, "SECTION");
|
|
w(2, "HEADER");
|
|
w(9, "$INSUNITS");
|
|
w(70, "4");
|
|
w(0, "ENDSEC");
|
|
w(0, "SECTION");
|
|
w(2, "ENTITIES");
|
|
let f = |x: f64| format!("{x:.9}");
|
|
for g in geom {
|
|
match g {
|
|
Geom2::Point(p) => {
|
|
w(0, "POINT");
|
|
w(8, "0");
|
|
w(10, &f(p.x));
|
|
w(20, &f(p.y));
|
|
}
|
|
Geom2::Line(a, b) => {
|
|
w(0, "LINE");
|
|
w(8, "0");
|
|
w(10, &f(a.x));
|
|
w(20, &f(a.y));
|
|
w(11, &f(b.x));
|
|
w(21, &f(b.y));
|
|
}
|
|
Geom2::Circle(c, r) => {
|
|
w(0, "CIRCLE");
|
|
w(8, "0");
|
|
w(10, &f(c.x));
|
|
w(20, &f(c.y));
|
|
w(40, &f(*r));
|
|
}
|
|
Geom2::Arc { c, a, b } => {
|
|
w(0, "ARC");
|
|
w(8, "0");
|
|
w(10, &f(c.x));
|
|
w(20, &f(c.y));
|
|
w(40, &f(c.dist(*a)));
|
|
w(50, &f((*a - *c).angle().to_degrees()));
|
|
w(51, &f((*b - *c).angle().to_degrees()));
|
|
}
|
|
Geom2::Text { text, at, height, angle } => {
|
|
w(0, "TEXT");
|
|
w(8, "0");
|
|
w(10, &f(at.x));
|
|
w(20, &f(at.y));
|
|
w(40, &f(*height));
|
|
w(50, &f(angle.to_degrees()));
|
|
w(1, &text.replace('\n', " "));
|
|
}
|
|
// Free-form curves are written by the caller as polylines (lines).
|
|
Geom2::Ellipse { .. } | Geom2::Spline { .. } | Geom2::Conic { .. } => {}
|
|
}
|
|
}
|
|
w(0, "ENDSEC");
|
|
w(0, "EOF");
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn round_trip_and_polyline_bulges() {
|
|
let g = vec![
|
|
Geom2::Line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)),
|
|
Geom2::Circle(Vec2::new(5.0, 5.0), 2.0),
|
|
Geom2::Arc { c: Vec2::ZERO, a: Vec2::new(3.0, 0.0), b: Vec2::new(0.0, 3.0) },
|
|
];
|
|
let back = read_dxf(&write_dxf(&g)).unwrap();
|
|
assert_eq!(back.len(), 3);
|
|
match &back[2] {
|
|
Geom2::Arc { c, a, b } => {
|
|
assert!(c.dist(Vec2::ZERO) < 1e-6 && a.dist(Vec2::new(3.0, 0.0)) < 1e-6 && b.dist(Vec2::new(0.0, 3.0)) < 1e-6);
|
|
}
|
|
x => panic!("{x:?}"),
|
|
}
|
|
// A closed LWPOLYLINE slot: two lines and two half circles (bulge 1), in inches.
|
|
let dxf = "0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n1\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n90\n4\n70\n1\n10\n0\n20\n0\n10\n2\n20\n0\n42\n1\n10\n2\n20\n1\n10\n0\n20\n1\n42\n1\n0\nENDSEC\n0\nEOF\n";
|
|
let g = read_dxf(dxf).unwrap();
|
|
assert_eq!(g.len(), 4, "{g:?}");
|
|
match &g[1] {
|
|
Geom2::Arc { c, a, .. } => {
|
|
assert!(c.dist(Vec2::new(50.8, 12.7)) < 1e-9, "{c:?}");
|
|
assert!((c.dist(*a) - 12.7).abs() < 1e-9);
|
|
}
|
|
x => panic!("{x:?}"),
|
|
}
|
|
assert!(read_dxf("garbage").is_err());
|
|
assert!(read_dxf("AutoCAD Binary DXF\r\n").is_err());
|
|
}
|
|
}
|