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};
+8 -3
View File
@@ -158,15 +158,20 @@ consume an undo step.
{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}}
```
DXF cutout geometry preserves lines, circles and circular arcs; other curves are sampled
adaptively. The `OUTLINE`, `BEND_UP` and `BEND_DOWN` layers separate cuts from bend centres.
The DXF is an AutoCAD 2000 (`AC1015`) drawing in millimetres. The `OUTLINE`, `BEND_UP` and
`BEND_DOWN` layers separate cuts from bend centres. A round hole is a `CIRCLE`, a circular arc an
`ARC`, a whole ellipse one `ELLIPSE` and an arc of an ellipse an elliptical `ELLIPSE`; the pieces
of a spline, the outlines of text and the other conics are `SPLINE`s of their own degree, so no
curve is written as chords. For a machine that reads only lines and arcs, pass
`"curves": "arcs"` to `sheet.export_dxf`: the free-form curves are then tangent arcs and lines
within 0.001 mm.
Base-flange profiles keep their interior holes. Through cuts that cross a sheet edge become
notches in the flat outline; overlapping and repeated cuts remove their combined area once.
The flat preview, DXF and unfolded solid use the same material boundaries. Lines and circular
arcs remain exact. An ellipse, a spline or text stays the curve it was drawn as in the flat
pattern and the unfolded solid; the folded solid follows it with tangent arcs within 0.001 mm,
and the DXF and the point lists write it as chords within 0.001 mm.
and the point lists write it as chords within 0.001 mm (the DXF keeps the curve, see above).
Flat-pattern JSON lists each cut-out twice, in the same order: as points (`cutouts`), and
exactly (`cutout_shapes`): a round hole as `{"circle": {"center", "radius"}}`, any other as its
`line`, `arc` (centre, radius, start and sweep in degrees) and `polyline` segments (one