Files
solidcraft-upstream/crates/engine/src/cmd/sheet_dxf_tests.rs
T
NOWN-DEV 764fcaa5a8 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.
2026-10-14 14:09:46 -04:00

222 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}