From 764fcaa5a8f768012207a2343ac4c6843f365bf2 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:09:46 +0200 Subject: [PATCH] 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. --- crates/engine/src/cmd/sheet.rs | 139 ++-- crates/engine/src/cmd/sheet_convert_tests.rs | 4 +- crates/engine/src/cmd/sheet_dxf_tests.rs | 221 ++++++ crates/engine/src/cmd/sheet_tests.rs | 17 +- crates/io/src/dxf.rs | 3 +- crates/io/src/dxf_out.rs | 670 +++++++++++++++++++ crates/io/src/lib.rs | 2 + docs/book/src/guide/sheet-metal.md | 11 +- 8 files changed, 972 insertions(+), 95 deletions(-) create mode 100644 crates/engine/src/cmd/sheet_dxf_tests.rs create mode 100644 crates/io/src/dxf_out.rs diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 48553171..affd1640 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -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 { flat_json(&sh) } -/// A DXF (R12 entities, mm) of the flat pattern on named layers. -pub fn flat_dxf(sh: &SheetBody) -> Result { +/// 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 { + 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 { 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 = 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 { 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; diff --git a/crates/engine/src/cmd/sheet_convert_tests.rs b/crates/engine/src/cmd/sheet_convert_tests.rs index c8e9c3c6..739882af 100644 --- a/crates/engine/src/cmd/sheet_convert_tests.rs +++ b/crates/engine/src/cmd/sheet_convert_tests.rs @@ -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")) } diff --git a/crates/engine/src/cmd/sheet_dxf_tests.rs b/crates/engine/src/cmd/sheet_dxf_tests.rs new file mode 100644 index 00000000..993532cb --- /dev/null +++ b/crates/engine/src/cmd/sheet_dxf_tests.rs @@ -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 { + 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 = 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 { + 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 { + 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 { + 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 = 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::>()); + // 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 = 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 = 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::>()); + let arcs: Vec<(Vec2, f64, [Vec2; 2], Vec)> = 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::>(); + (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 = 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")); +} diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index cab51340..fe5a5280 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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)); diff --git a/crates/io/src/dxf.rs b/crates/io/src/dxf.rs index 5577a265..2d8be420 100644 --- a/crates/io/src/dxf.rs +++ b/crates/io/src/dxf.rs @@ -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; diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs new file mode 100644 index 00000000..161a80c2 --- /dev/null +++ b/crates/io/src/dxf_out.rs @@ -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, + ctrl: Vec, + weights: Option>, + }, +} + +/// 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 { + let arcs: Option> = + 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::() - 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 { + // 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>) -> 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) { + 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 { + 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>| { + *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 = + 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 = 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::>(), ["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); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 1ecc1fbb..35d4d903 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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}; diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 5adb0e8c..bb081825 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -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