Sheet metal: the flat-pattern DXF writes ellipses and splines exactly (#1096)

sheet.export_dxf writes an AutoCAD 2000 drawing with ELLIPSE and SPLINE entities for ellipses, splines and text outlines instead of chords, with curves: arcs for machines that read only lines and arcs. Perforation circles go through the same writer. Closes #1089.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-14 14:09:46 -04:00
1 parent 9a4e8fbd19
commit 764fcaa5a8
8 files changed
+972 -95

No files matched your search

+54 -85
View File
@@ -60,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
.params("body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those) → DXF (AutoCAD 2000, mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
.noundo()
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
@@ -491,8 +491,20 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
flat_json(&sh)
}
/// A DXF (R12 entities, mm) of the flat pattern on named layers.
pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
/// How the flat DXF writes the curves that are neither lines nor circular arcs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfCurves {
/// Exactly: an ellipse as ELLIPSE, Béziers (spline pieces, text outlines) and the other conics as SPLINE.
#[default]
Exact,
/// As tangent arcs and lines within 0.001 mm, for machines that read only those.
Arcs,
}
/// A DXF (AutoCAD 2000, mm) of the flat pattern on named layers: OUTLINE, BEND_UP and BEND_DOWN
/// (centre lines), and one layer per pocket depth. Free-form curves as `curves` says.
pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
use solidcraft_io::{DxfEntity, DxfLayer, DxfShape};
let regions = sh.flat_regions()?;
let pockets = sh.machining()?;
let pocket_layer = |p: &solidcraft_doc::sheet::SheetPocket| {
@@ -520,110 +532,63 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
if !perforation.is_empty() {
layers.insert("PERFORATION".to_string());
}
let mut out = String::new();
let mut w = |c: i32, v: &str| out.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, "TABLES");
w(0, "TABLE");
w(2, "LAYER");
w(70, &layers.len().to_string());
for name in &layers {
let colour = match name.as_str() {
"OUTLINE" => "7",
"BEND_UP" => "1",
"BEND_DOWN" => "5",
_ => "3",
};
w(0, "LAYER");
w(2, name);
w(70, "0");
w(62, colour);
w(6, "CONTINUOUS");
}
w(0, "ENDTAB");
w(0, "ENDSEC");
w(0, "SECTION");
w(2, "ENTITIES");
let f = |x: f64| format!("{x:.6}");
let line = |w: &mut dyn FnMut(i32, &str), layer: &str, a: Vec2, b: Vec2| {
w(0, "LINE");
w(8, layer);
w(10, &f(a.x));
w(20, &f(a.y));
w(11, &f(b.x));
w(21, &f(b.y));
};
let layers: Vec<DxfLayer> = layers
.into_iter()
.map(|name| {
let color = match name.as_str() {
"OUTLINE" => 7,
"BEND_UP" => 1,
"BEND_DOWN" => 5,
_ => 3,
};
DxfLayer { name, color }
})
.collect();
let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect();
for p in &pockets {
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
loops.push((lp, pocket_layer(p)));
}
}
let mut entities = Vec::new();
for (h, layer) in loops {
// A circle is often represented by two semicircles. Keep the whole analytic
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
if let Some((center, radius)) = loop_circle(h) {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
entities.push(DxfEntity { layer, shape: DxfShape::Circle { c: center, r: radius } });
continue;
}
for seg in &h.segs {
match *seg {
solidcraft_geom::Seg2::Arc { center, radius, start, sweep } => {
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
continue;
}
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
w(0, "ARC");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
w(50, &f(a0.to_degrees().rem_euclid(360.0)));
w(51, &f(a1.to_degrees().rem_euclid(360.0)));
}
other => {
let pts = other.polyline(1e-3);
for p in pts.windows(2) {
line(&mut w, &layer, p[0], p[1]);
}
}
let fitted;
let h = match curves {
DxfCurves::Arcs if !h.is_line_arc() => {
fitted = h
.fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
.ok_or_else(|| EngineError::Other("a flat-pattern curve could not be written as arcs".into()))?;
&fitted
}
}
_ => h,
};
// A whole ellipse is one ELLIPSE; every other segment is an entity of its own.
entities.extend(solidcraft_io::loop_shapes(h).into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
}
for b in sh.bends() {
line(&mut w, if b.up { "BEND_UP" } else { "BEND_DOWN" }, b.start, b.end);
entities.push(DxfEntity { layer: if b.up { "BEND_UP" } else { "BEND_DOWN" }.into(), shape: DxfShape::Line(b.start, b.end) });
}
for (c, r) in &perforation {
w(0, "CIRCLE");
w(8, "PERFORATION");
w(10, &c.x.to_string());
w(20, &c.y.to_string());
w(30, "0");
w(40, &r.to_string());
for (c, r) in perforation {
entities.push(DxfEntity { layer: "PERFORATION".into(), shape: DxfShape::Circle { c, r } });
}
w(0, "ENDSEC");
w(0, "EOF");
Ok(out)
Ok(solidcraft_io::write_dxf_drawing(&layers, &entities))
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_dxf";
let curves = match str_(p, "curves").map(str::to_ascii_lowercase).as_deref() {
None | Some("exact") => DxfCurves::Exact,
Some("arcs") => DxfCurves::Arcs,
Some(_) => return Err(bad(cmd, "`curves` must be exact or arcs")),
};
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh)?;
let text = flat_dxf(&sh, curves)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "bends": sh.bends().len()}));
@@ -682,3 +647,7 @@ mod tests;
#[cfg(test)]
#[path = "sheet_convert_tests.rs"]
mod convert_tests;
#[cfg(test)]
#[path = "sheet_dxf_tests.rs"]
mod dxf_tests;
+3 -1
View File
@@ -637,7 +637,9 @@ fn flat_circles(s: &mut Session) -> Vec<(f64, [f64; 2])> {
/// How many entities of each kind the flat pattern's DXF has.
fn dxf_entities(s: &mut Session) -> (usize, usize, usize) {
let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or("").replace("\r\n", "\n");
let n = |kind: &str| dxf.matches(&format!("\n0\n{kind}\n")).count();
// Group codes may be padded (` 0`): compare the code and value lines trimmed.
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let n = |kind: &str| lines.chunks(2).filter(|pair| pair == &["0", kind]).count();
(n("LINE"), n("ARC"), n("CIRCLE"))
}
+221
View File
@@ -0,0 +1,221 @@
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities.
use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use solidcraft_geom::{Seg2, Vec2};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// A 100 × 60 base flange (the default 2.5 mm rule) with `curves` drawn in a sketch and cut through it.
fn plate_with(curves: &[(&str, Value)]) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
for (command, params) in curves {
run(&mut s, command, params.clone());
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
s
}
fn export(s: &mut Session, params: Value) -> String {
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
}
/// The entities of a DXF text: type and group codes.
type Entity = (String, Vec<(i32, String)>);
fn entities(dxf: &str) -> Vec<Entity> {
let lines: Vec<&str> = dxf.lines().collect();
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap_or(0);
let mut out: Vec<Entity> = Vec::new();
for (code, value) in pairs.iter().skip(start + 1) {
match (*code, *value) {
(0, "ENDSEC") => break,
(0, kind) => out.push((kind.to_string(), Vec::new())),
_ => {
if let Some(last) = out.last_mut() {
last.1.push((*code, value.to_string()));
}
}
}
}
out
}
fn values(e: &Entity, code: i32) -> Vec<f64> {
e.1.iter().filter(|(c, _)| *c == code).filter_map(|(_, v)| v.parse().ok()).collect()
}
fn count(es: &[Entity], kind: &str) -> usize {
es.iter().filter(|e| e.0 == kind).count()
}
fn point(e: &Entity, code: i32) -> Vec2 {
Vec2::new(values(e, code).first().copied().unwrap_or(f64::NAN), values(e, code + 10).first().copied().unwrap_or(f64::NAN))
}
/// All loops of the flat pattern (outlines, cut-outs, islands).
fn loops(s: &Session) -> Vec<solidcraft_geom::Loop2> {
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
regions.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.clone())).collect()
}
/// The profiles of the DXF read into a new sketch: their areas, largest first.
fn reimported_areas(dxf: &str) -> Vec<f64> {
let mut s = Session::default();
let id = run(&mut s, "sketch.insert_dxf", json!({"text": dxf, "name": "Back"}))["sketch"].as_u64().unwrap_or(0);
run(&mut s, "sketch.finish", json!({}));
let mut areas: Vec<f64> = s.model.state().sketch(id).map(|ss| ss.profiles.iter().map(|p| p.area).collect()).unwrap_or_default();
areas.sort_by(|a, b| b.total_cmp(a));
areas
}
#[test]
fn an_elliptic_cut_out_is_one_ellipse() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// The four lines of the outline and one ELLIPSE: no other entity.
assert_eq!((count(&es, "LINE"), count(&es, "ELLIPSE"), es.len()), (4, 1, 5), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
// By hand: centre (40, 30), the major semi-axis 15 along x (to (55, 30) or (25, 30)), the
// minor one 8, so the ratio is 8/15; the whole turn runs from parameter 0 to 2π.
let e = es.iter().find(|e| e.0 == "ELLIPSE").unwrap();
let (c, major) = (point(e, 10), point(e, 11));
assert!(c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.x.abs() - 15.0).abs() < 1e-9 && major.y.abs() < 1e-9, "{c:?} {major:?}");
assert!((values(e, 40)[0] - 8.0 / 15.0).abs() < 1e-9 && values(e, 41)[0] == 0.0 && (values(e, 42)[0] - TAU).abs() < 1e-9);
assert_eq!(values(e, 230), [1.0]);
// Our reader gets the same ellipse back, and the same plate: 6000 mm² less π·15·8.
let read = solidcraft_io::read_dxf(&dxf).unwrap();
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Ellipse { c, major, minor }
if c.dist(Vec2::new(40.0, 30.0)) < 1e-9 && (major.dist(*c) - 15.0).abs() < 1e-9 && (minor - 8.0).abs() < 1e-9)));
let areas = reimported_areas(&dxf);
assert!(areas.len() == 2 && (areas[0] - (6000.0 - PI * 120.0)).abs() < 1e-6 && (areas[1] - PI * 120.0).abs() < 1e-6, "{areas:?}");
}
#[test]
fn a_spline_cut_out_is_the_splines_it_was() {
let points = json!([[20, 15], [40, 45], [65, 40], [85, 20]]);
let mut s = plate_with(&[("sketch.spline.fit_point", json!({"points": points})), ("sketch.line", json!({"points": [[85, 20], [20, 15]]}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// The cut-out's segments of the flat pattern, one entity each: its cubic pieces are SPLINEs
// of degree 3 over the same four control points, its line a LINE.
let hole: Vec<Seg2> = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default();
let cubics: Vec<[Vec2; 4]> =
hole.iter().filter_map(|g| if let Seg2::Cubic { p0, p1, p2, p3 } = g { Some([*p0, *p1, *p2, *p3]) } else { None }).collect();
assert!(!cubics.is_empty() && cubics.len() + 1 == hole.len(), "{hole:?}");
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (5, cubics.len(), 5 + cubics.len()));
for (spline, want) in es.iter().filter(|e| e.0 == "SPLINE").zip(&cubics) {
assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![3.0], vec![8.0], vec![4.0]));
assert_eq!(values(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
let (xs, ys) = (values(spline, 10), values(spline, 20));
assert!(want.iter().enumerate().all(|(i, p)| (xs[i] - p.x).abs() < 1e-9 && (ys[i] - p.y).abs() < 1e-9), "{xs:?} {ys:?} {want:?}");
}
// The same plate comes back.
let areas = reimported_areas(&dxf);
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area();
assert!((areas[0] - plate).abs() < 1e-6, "{areas:?} {plate}");
}
#[test]
fn text_is_splines_and_lines_not_chords() {
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
let dxf = export(&mut s, json!({}));
let es = entities(&dxf);
// One entity for each segment of the flat pattern: a straight one a LINE, a glyph's
// quadratic and cubic curves SPLINEs of degree 2 and 3.
let segs: Vec<Seg2> = loops(&s).iter().flat_map(|l| l.segs.clone()).collect();
let n = |f: &dyn Fn(&Seg2) -> bool| segs.iter().filter(|g| f(g)).count();
let (lines, quadratics, cubics) = (
n(&|g| matches!(g, Seg2::Line { .. })),
n(&|g| matches!(g, Seg2::Conic { w, .. } if (*w - 1.0).abs() < 1e-12)),
n(&|g| matches!(g, Seg2::Cubic { .. })),
);
assert_eq!(lines + quadratics + cubics, segs.len(), "only lines and Béziers");
assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (lines, quadratics + cubics, segs.len()));
let degree = |d: f64| es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [d]).count();
assert_eq!((degree(2.0), degree(3.0)), (quadratics, cubics));
// The plate with the letters cut out comes back from the file with its area (the largest region; the islands inside the
// letters come in no fixed order).
let areas = reimported_areas(&dxf);
let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default().iter().map(|r| r.area()).fold(0.0, f64::max);
assert!((areas[0] - plate).abs() < 1e-3, "{areas:?} {plate}");
}
#[test]
fn arcs_option_writes_only_lines_and_tangent_arcs_within_a_thousandth() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
let dxf = export(&mut s, json!({"curves": "arcs"}));
let es = entities(&dxf);
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", es.iter().map(|e| &e.0).collect::<Vec<_>>());
let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec<Vec2>)> = es
.iter()
.filter(|e| e.0 == "ARC")
.map(|e| {
let (c, r, (a0, a1)) = (point(e, 10), values(e, 40)[0], (values(e, 50)[0].to_radians(), values(e, 51)[0].to_radians()));
let sweep = (a1 - a0).rem_euclid(TAU);
let samples = (0..=20).map(|k| c + Vec2::from_angle(a0 + sweep * f64::from(k) / 20.0) * r).collect::<Vec<Vec2>>();
(c, r, [c + Vec2::from_angle(a0) * r, c + Vec2::from_angle(a1) * r], samples)
})
.collect();
// Far fewer than the 336 chords of 0.001 mm.
assert!(arcs.len() >= 8 && arcs.len() < 100, "{} arcs", arcs.len());
// Distance to the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1, to first order |f| / |∇f|.
let off = |p: Vec2| {
let (x, y) = (p.x - 40.0, p.y - 30.0);
let f = (x / 15.0).powi(2) + (y / 8.0).powi(2) - 1.0;
f.abs() / Vec2::new(2.0 * x / 225.0, 2.0 * y / 64.0).len()
};
// (The joint of a biarc's two arcs lies within the tolerance of the curve, not on it.)
for (_, _, ends, samples) in &arcs {
let offs: Vec<f64> = samples.iter().map(|p| off(*p)).collect();
assert!(offs.iter().all(|d| *d <= 1e-3 + 1e-6), "{ends:?}: {offs:?}");
}
// Tangent: at each joint the radii of the two arcs lie on one line.
for (i, (c, r, ends, _)) in arcs.iter().enumerate() {
for end in ends {
let others: Vec<_> = arcs.iter().enumerate().filter(|(j, (_, _, e, _))| *j != i && e.iter().any(|q| q.dist(*end) < 1e-6)).collect();
assert_eq!(others.len(), 1, "joint {end:?}");
let (_, (c2, r2, _, _)) = others[0];
assert!((*end - *c).cross(*end - *c2).abs() < 1e-6 * r * r2, "arcs are not tangent at {end:?}");
}
}
// The plate, read back, is the exact one within the perimeter times 0.001 mm.
let areas = reimported_areas(&dxf);
assert!((areas[0] - (6000.0 - PI * 120.0)).abs() < 0.1, "{areas:?}");
}
#[test]
fn arcs_option_has_no_splines_for_text_and_the_curves_value_is_checked() {
let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
let es = entities(&export(&mut s, json!({"curves": "ARCS"})));
assert!(es.iter().all(|e| e.0 == "LINE" || e.0 == "ARC") && count(&es, "ARC") > 0);
let exact = entities(&export(&mut s, json!({"curves": "exact"})));
assert!(count(&exact, "SPLINE") > 0 && count(&exact, "ARC") == 0);
assert!(s.execute("sheet.export_dxf", &json!({"curves": "chords"})).is_err());
}
#[test]
fn bend_lines_and_layers_are_still_there() {
let mut s = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let out = run(&mut s, "sheet.export_dxf", json!({}));
let es = entities(out["dxf"].as_str().unwrap_or_default());
let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count();
assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize);
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("OUTLINE") == es.len() - on("BEND_UP") - on("BEND_DOWN"));
}
+12 -5
View File
@@ -1519,7 +1519,7 @@ fn flat_dxf_preserves_exact_cutout_entities() {
outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
holes: Vec::new(),
}];
let dxf = super::flat_dxf(&sh).unwrap();
let dxf = super::flat_dxf(&sh, super::DxfCurves::Exact).unwrap();
assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
let read = solidcraft_io::read_dxf(&dxf).unwrap();
@@ -1531,10 +1531,10 @@ fn flat_dxf_preserves_exact_cutout_entities() {
Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
],
};
let dxf = super::flat_dxf(&sh).unwrap();
let dxf = super::flat_dxf(&sh, super::DxfCurves::Exact).unwrap();
assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
assert_eq!(dxf.matches("0\nARC\n").count(), 1);
assert!(dxf.contains("50\n0.000000\n51\n180.000000\n"));
assert!(dxf.contains(" 50\n0.0\n 51\n180.0\n"), "{dxf}");
let read = solidcraft_io::read_dxf(&dxf).unwrap();
assert_eq!(read.len(), 6);
assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9)));
@@ -2921,7 +2921,11 @@ fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
}
} else {
assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}");
let corner = Vec2::new(s0 + 4.0, s0 + 4.0);
let read = solidcraft_io::read_dxf(&dxf).unwrap_or_default();
let at_corner =
|g: &solidcraft_io::Geom2| matches!(g, solidcraft_io::Geom2::Line(a, b) if a.dist(corner) < 1e-6 || b.dist(corner) < 1e-6);
assert!(arcs.is_empty() && read.iter().any(at_corner), "{arcs:?}");
}
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
run(&mut s, "sheet.unfold", json!({}));
@@ -2992,7 +2996,10 @@ fn a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf() {
// The DXF: the outline in straight lines, each cut one of them, and four bend lines.
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{deg}");
assert!(dxf.contains(&format!("{:.6}\n20\n{:.6}\n", x_end(s0), s0 - ba)), "{deg}");
let cut_end = solidcraft_geom::Vec2::new(x_end(s0), s0 - ba);
let read = solidcraft_io::read_dxf(&dxf).unwrap_or_default();
let at_cut_end = |g: &solidcraft_io::Geom2| matches!(g, solidcraft_io::Geom2::Line(a, b) if a.dist(cut_end) < 1e-6 || b.dist(cut_end) < 1e-6);
assert!(read.iter().any(at_cut_end), "{deg}");
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - flat0 - want).abs() < 1e-6, "{deg}: unfolded {} - {flat0} vs {want}", volume(&mut s));
+2 -1
View File
@@ -2,7 +2,8 @@
//! LWPOLYLINE, POLYLINE/VERTEX, POINT, ELLIPSE, SPLINE, TEXT and MTEXT, scaled to millimetres by
//! `$INSUNITS`. Block references (INSERT, with scale, rotation and arrays) are expanded,
//! nested blocks too; paper-space entities are skipped. The writer emits an R12-style file of lines, circles,
//! arcs and points (free-form curves as polylines).
//! arcs and points (free-form curves as polylines); the flat pattern's drawing, with ELLIPSE and SPLINE, is
//! [`crate::write_dxf_drawing`].
use std::collections::HashMap;
+670
View File
@@ -0,0 +1,670 @@
//! 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. 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: its name and AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black).
#[derive(Clone, Debug, PartialEq)]
pub struct DxfLayer {
pub name: String,
pub color: i16,
}
/// The geometry of one entity (millimetres).
#[derive(Clone, Debug, PartialEq)]
pub enum DxfShape {
Line(Vec2, 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>>,
},
}
/// 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::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()),
}
}
}
/// 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 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 { name: "0".into(), color: 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");
let t = o.table("LTYPE", 3);
for (name, description) in [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")] {
o.record("LTYPE", "AcDbLinetypeTableRecord", &t);
o.code(2, name);
o.code(70, 0);
o.code(3, description);
o.code(72, 65);
o.code(73, 0);
o.num(40, 0.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, "Continuous");
}
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::Circle { .. } => "CIRCLE",
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
DxfShape::Spline { .. } => "SPLINE",
};
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::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);
}
}
}
}
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; 8];
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 { name: "OUTLINE".into(), color: 7 }, DxfLayer { name: "CUT".into(), color: 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);
}
}
+2
View File
@@ -10,6 +10,7 @@ pub mod catalog;
mod design;
mod design_metadata;
mod dxf;
mod dxf_out;
pub use design::{
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
@@ -25,6 +26,7 @@ pub mod urdf;
pub mod vfs;
pub use dxf::{read_dxf, write_dxf};
pub use dxf_out::{DxfEntity, DxfLayer, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
pub use safe_write::{backup_path, write_atomic};
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};