Closes #1284. sheet.export_dxf writes layers by role (OUTLINE, CUT, TEXT or ETCH, dashed BEND_UP/BEND_DOWN, optional BEND_NOTES, POCKET_<side>_Z…_MM and PERFORATION), inner contours before the outline; text: cut|etch, bend_notes and note_height, per_body files. Letters are derived from the timeline, nothing new is saved (format unchanged). I merged main in (the export's parameter list keeps main's pocket layer names). Checked on main + this branch: cargo check --workspace, the engine cmd::sheet tests (160) and the io DXF tests.
523 lines
27 KiB
Rust
523 lines
27 KiB
Rust
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities, and
|
||
//! a laser-cutting shop can use it as it is: layers by role, bend notes, letters cut or etched,
|
||
//! one file per sheet body.
|
||
|
||
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 {
|
||
plate_in(Session::default(), curves)
|
||
}
|
||
|
||
/// A 100 × 60 base flange in `s` (of its rule), with `curves` drawn in a sketch and cut through it.
|
||
fn plate_in(mut s: Session, curves: &[(&str, Value)]) -> Session {
|
||
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"}));
|
||
if curves.is_empty() {
|
||
return s;
|
||
}
|
||
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
|
||
}
|
||
|
||
/// A session whose active rule is 2 mm sheet with an inner bend radius of 1 mm.
|
||
fn sheet_of_2mm() -> Session {
|
||
let mut s = Session::default();
|
||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||
s
|
||
}
|
||
|
||
/// A 100 × 60 tray of 2 mm sheet with `curves` cut through its base: three 20 mm walls bend up and
|
||
/// one bends down.
|
||
fn tray_with(curves: &[(&str, Value)]) -> Session {
|
||
let mut s = plate_in(sheet_of_2mm(), curves);
|
||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2]], "height": 20}));
|
||
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2]], "height": 20, "flip": true}));
|
||
s
|
||
}
|
||
|
||
/// What a tray is marked with: a Ø5 hole, a 20 × 5 slot and the label "AB-12", 10 mm high at (20, 28).
|
||
fn marks() -> Vec<(&'static str, Value)> {
|
||
vec![
|
||
("sketch.circle.center", json!({"center": [15, 15], "diameter": 5})),
|
||
("sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]})),
|
||
("sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10})),
|
||
]
|
||
}
|
||
|
||
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);
|
||
// The ellipse is a cut-out, on CUT; the outline is the plate's.
|
||
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
|
||
assert_eq!(on("OUTLINE") + on("CUT"), es.len() - on("BEND_UP") - on("BEND_DOWN"));
|
||
}
|
||
|
||
/// The layer an entity is on.
|
||
fn layer_of(e: &Entity) -> String {
|
||
e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// The text of an entity's group code.
|
||
fn string(e: &Entity, code: i32) -> String {
|
||
e.1.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default()
|
||
}
|
||
|
||
/// What the entities of a layer are, without their handles and the layer: to compare layers of two files.
|
||
fn on_layer(es: &[Entity], layer: &str) -> Vec<(String, Vec<(i32, String)>)> {
|
||
es.iter().filter(|e| layer_of(e) == layer).map(|e| (e.0.clone(), e.1.iter().filter(|(c, _)| ![5, 8].contains(c)).cloned().collect())).collect()
|
||
}
|
||
|
||
/// The LAYER table: name → (colour, linetype).
|
||
fn layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
|
||
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, "LAYER")).unwrap_or(0);
|
||
let mut records: Vec<Vec<(i32, &str)>> = Vec::new();
|
||
for p in pairs.iter().skip(start + 1) {
|
||
match *p {
|
||
(0, "ENDTAB") => break,
|
||
(0, _) => records.push(Vec::new()),
|
||
_ => {
|
||
if let Some(record) = records.last_mut() {
|
||
record.push(*p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
records
|
||
.iter()
|
||
.filter_map(|r| {
|
||
let get = |code: i32| r.iter().find(|(c, _)| *c == code).map(|(_, v)| *v);
|
||
Some((get(2)?.to_string(), (get(62)?.parse().ok()?, get(6)?.to_string())))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn vec2(v: &Value) -> Vec2 {
|
||
Vec2::new(v[0].as_f64().unwrap_or(f64::NAN), v[1].as_f64().unwrap_or(f64::NAN))
|
||
}
|
||
|
||
#[test]
|
||
fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
|
||
let mut s = tray_with(&marks());
|
||
let out = run(&mut s, "sheet.export_dxf", json!({}));
|
||
let dxf = out["dxf"].as_str().unwrap_or_default();
|
||
let es = entities(dxf);
|
||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||
// OUTLINE: the tray's outer contour alone, one entity for each of its segments (lines and arcs).
|
||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||
let plate =
|
||
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
|
||
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
|
||
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
|
||
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
|
||
let cut = on("CUT");
|
||
assert_eq!((count_of(&cut, "CIRCLE"), count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||
// TEXT: the letters of "AB-12" and the counters of A and B, all within the label's box (5 letters
|
||
// of at most 10 mm from (20, 28)).
|
||
let letters = on("TEXT");
|
||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||
for e in &letters {
|
||
let p = point(e, 10);
|
||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||
}
|
||
// The bend lines by direction: three walls up, one down.
|
||
assert_eq!((on("BEND_UP").len(), on("BEND_DOWN").len(), out["bends"].as_u64()), (3, 1, Some(4)));
|
||
// Inner contours are written before the outline, so a program that follows the file cuts inside-out.
|
||
let last_inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT"));
|
||
let first_outline = es.iter().position(|e| layer_of(e) == "OUTLINE");
|
||
let (Some(last_inner), Some(first_outline)) = (last_inner, first_outline) else { panic!("{last_inner:?} {first_outline:?}") };
|
||
assert!(last_inner < first_outline, "an inner contour at {last_inner}, the outline from {first_outline}");
|
||
// The layers, with the colours and linetypes the guide gives; nothing else is declared.
|
||
let want = [
|
||
("0", 7, "Continuous"),
|
||
("OUTLINE", 7, "Continuous"),
|
||
("CUT", 4, "Continuous"),
|
||
("TEXT", 2, "Continuous"),
|
||
("BEND_UP", 1, "DASHED"),
|
||
("BEND_DOWN", 5, "DASHED"),
|
||
];
|
||
assert_eq!(layer_table(dxf), want.map(|(n, c, l)| (n.to_string(), (c, l.to_string()))).into_iter().collect());
|
||
}
|
||
|
||
/// How many entities of `kind` there are among `list`.
|
||
fn count_of(list: &[&Entity], kind: &str) -> usize {
|
||
list.iter().filter(|e| e.0 == kind).count()
|
||
}
|
||
|
||
#[test]
|
||
fn letters_can_be_etched_instead_of_cut() {
|
||
let mut s = tray_with(&marks());
|
||
let cut = entities(&export(&mut s, json!({})));
|
||
let etched_dxf = export(&mut s, json!({"text": "etch"}));
|
||
let etched = entities(&etched_dxf);
|
||
// The same contours, on ETCH instead of TEXT: nothing of them is cut.
|
||
assert!(!on_layer(&cut, "TEXT").is_empty());
|
||
assert_eq!(on_layer(&etched, "ETCH"), on_layer(&cut, "TEXT"));
|
||
assert!(on_layer(&etched, "TEXT").is_empty());
|
||
// The hole and the slot are no text: they stay where they were, as do the outline and the bends.
|
||
for layer in ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"] {
|
||
assert_eq!(on_layer(&etched, layer), on_layer(&cut, layer), "{layer}");
|
||
}
|
||
let layers = layer_table(&etched_dxf);
|
||
assert_eq!(layers.get("ETCH"), Some(&(6, "Continuous".to_string())));
|
||
assert!(!layers.contains_key("TEXT"));
|
||
// `cut` is the default, and a value that is neither is refused.
|
||
assert_eq!(export(&mut s, json!({"text": "cut"})), export(&mut s, json!({})));
|
||
assert!(s.execute("sheet.export_dxf", &json!({"text": "fill"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn engraved_text_is_etched_and_other_pockets_keep_their_depth_layer() {
|
||
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": "Marks"}));
|
||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
|
||
run(&mut s, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
// Part way through the sheet: pockets, 1 mm deep from the bottom.
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
|
||
let dxf = export(&mut s, json!({}));
|
||
let es = entities(&dxf);
|
||
// The rectangle is a pocket of the depth its layer names; the letters are a marking, on ETCH.
|
||
let pocket = "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM";
|
||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||
assert_eq!((count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
|
||
let letters = on("ETCH");
|
||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||
for e in &letters {
|
||
let p = point(e, 10);
|
||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||
}
|
||
let layers = layer_table(&dxf);
|
||
assert!(layers.contains_key(pocket) && layers.contains_key("ETCH") && !layers.contains_key("TEXT"), "{layers:?}");
|
||
// `text` is about letters cut through: it changes nothing here.
|
||
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&es, "ETCH"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_bend_note_sits_beside_each_bend_line_when_asked_for() {
|
||
let mut s = tray_with(&[]);
|
||
let flat = run(&mut s, "sheet.flat_pattern", json!({}));
|
||
let bends = flat["bends"].as_array().cloned().unwrap_or_default();
|
||
assert_eq!(bends.len(), 4);
|
||
// Not unless asked for: a program that follows the file would etch them.
|
||
let plain = export(&mut s, json!({}));
|
||
assert!(entities(&plain).iter().all(|e| e.0 != "TEXT") && !layer_table(&plain).contains_key("BEND_NOTES"));
|
||
let dxf = export(&mut s, json!({"bend_notes": true}));
|
||
let notes: Vec<Entity> = entities(&dxf).into_iter().filter(|e| e.0 == "TEXT").collect();
|
||
assert_eq!(notes.len(), 4);
|
||
assert!(notes.iter().all(|e| layer_of(e) == "BEND_NOTES"));
|
||
assert_eq!(layer_table(&dxf).get("BEND_NOTES"), Some(&(8, "Continuous".to_string())));
|
||
for b in &bends {
|
||
// The walls bend by 90° round an inner radius of 1 mm: "B1 UP 90° R1" (`%%d` is the degree sign).
|
||
let up = b["direction"] == "up";
|
||
let text = format!("B{} {} 90%%d R1", b["bend"].as_u64().unwrap_or(0), if up { "UP" } else { "DOWN" });
|
||
let Some(note) = notes.iter().find(|e| string(e, 1) == text) else {
|
||
panic!("no note `{text}` among {:?}", notes.iter().map(|e| string(e, 1)).collect::<Vec<_>>())
|
||
};
|
||
// Centred (72 = 1), 3 mm high, parallel to the line: along x it reads left to right, along y upwards.
|
||
let (start, end) = (vec2(&b["start"]), vec2(&b["end"]));
|
||
let angle = if (end - start).x.abs() > (end - start).y.abs() { 0.0 } else { 90.0 };
|
||
assert!(values(note, 72) == [1.0] && values(note, 40) == [3.0] && (values(note, 50)[0] - angle).abs() < 1e-9, "{note:?}");
|
||
// On the left of the way it reads, half its height from the middle of the line.
|
||
let off = point(note, 11) - (start + end) * 0.5;
|
||
assert!(off.dist(Vec2::from_angle(angle.to_radians()).perp() * 1.5) < 1e-9, "{text}: {off:?}");
|
||
}
|
||
// The height is the caller's.
|
||
let tall = entities(&export(&mut s, json!({"bend_notes": true, "note_height": 5})));
|
||
let note = tall.iter().find(|e| e.0 == "TEXT");
|
||
assert!(note.is_some_and(|e| values(e, 40) == [5.0]));
|
||
}
|
||
|
||
/// Two sheet bodies: a lid and, 200 mm away, a side wall with one bend.
|
||
fn lid_and_wall() -> Session {
|
||
let mut s = Session::default();
|
||
for (sketch, p0, p1, body) in [("Lid", [0, 0], [100, 60], "Lid"), ("Wall", [200, 0], [250, 40], "Side Wall")] {
|
||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": sketch}));
|
||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "sheet.flange", json!({"sketch": sketch, "body_name": body}));
|
||
}
|
||
run(&mut s, "sheet.flange", json!({"edges": [[225, 0, 2.5]], "height": 15, "body": "Side Wall"}));
|
||
s
|
||
}
|
||
|
||
#[test]
|
||
fn each_sheet_body_can_have_a_file_of_its_own() {
|
||
let mut s = lid_and_wall();
|
||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true}));
|
||
let files = r["files"].as_array().cloned().unwrap_or_default();
|
||
assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap_or_default()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
|
||
assert_eq!(files.iter().map(|f| f["bends"].as_u64()).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||
// Each is the file `body` asks for: the lid's has no bend line, the wall's has its one.
|
||
for f in &files {
|
||
let single = export(&mut s, json!({"body": f["body"]}));
|
||
assert_eq!(f["dxf"].as_str(), Some(single.as_str()), "{}", f["body"]);
|
||
}
|
||
let bend_lines = |f: &Value| entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| layer_of(e) == "BEND_UP").count();
|
||
assert_eq!((bend_lines(&files[0]), bend_lines(&files[1])), (0, 1));
|
||
// With a path, each body is written to a file named after it, beside the path, and no text comes back.
|
||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-dxf-per-body-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap_or_default();
|
||
let path = dir.join("panel.dxf").to_string_lossy().to_string();
|
||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true, "path": path}));
|
||
let written = r["files"].as_array().cloned().unwrap_or_default();
|
||
let names: Vec<_> = written
|
||
.iter()
|
||
.map(|f| std::path::Path::new(f["path"].as_str().unwrap_or_default()).file_name().map(|n| n.to_string_lossy().to_string()))
|
||
.collect();
|
||
assert_eq!(names, [Some("panel-Lid.dxf".to_string()), Some("panel-Side Wall.dxf".to_string())]);
|
||
for (f, written) in files.iter().zip(&written) {
|
||
assert!(written.get("dxf").is_none());
|
||
let on_disk = std::fs::read_to_string(written["path"].as_str().unwrap_or_default()).unwrap_or_default();
|
||
assert_eq!(f["dxf"].as_str(), Some(on_disk.as_str()));
|
||
}
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn bad_export_options_are_refused_not_ignored() {
|
||
let mut s = tray_with(&[]);
|
||
for p in [
|
||
json!({"text": "fill"}),
|
||
json!({"text": 1}),
|
||
json!({"bend_notes": "yes"}),
|
||
json!({"bend_notes": 1}),
|
||
json!({"note_height": 0}),
|
||
json!({"note_height": -3}),
|
||
json!({"note_height": 1e9}),
|
||
json!({"note_height": "tall"}),
|
||
json!({"per_body": "yes"}),
|
||
json!({"per_body": true, "body": "Body1"}),
|
||
] {
|
||
assert!(s.execute("sheet.export_dxf", &p).is_err(), "{p}");
|
||
}
|
||
// Nothing was left half done.
|
||
assert!(s.execute("sheet.export_dxf", &json!({})).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn the_file_of_a_body_is_named_after_it_and_no_two_share_a_name() {
|
||
// (Windows writes the separator it adds as `\`.)
|
||
let names = |path: &str, bodies: &[&str]| -> Vec<String> {
|
||
super::body_paths(path, &bodies.iter().map(|b| b.to_string()).collect::<Vec<_>>()).into_iter().map(|p| p.replace('\\', "/")).collect()
|
||
};
|
||
assert_eq!(names("out/panel.dxf", &["Lid", "Side Wall"]), ["out/panel-Lid.dxf", "out/panel-Side Wall.dxf"]);
|
||
// The extension is the path's, and .dxf when it has none.
|
||
assert_eq!(names("out/panel", &["Lid"]), ["out/panel-Lid.dxf"]);
|
||
assert_eq!(names("panel.DXF", &["Lid"]), ["panel-Lid.DXF"]);
|
||
// What a file name cannot hold becomes `_`; a name that is nothing but that stays a name.
|
||
assert_eq!(names("p.dxf", &["a/b:c", " ", "wall."]), ["p-a_b_c.dxf", "p-body.dxf", "p-wall.dxf"]);
|
||
// Two bodies that would share a file (in any case) get a number.
|
||
assert_eq!(names("p.dxf", &["Lid", "lid", "LID", "Lid_2"]), ["p-Lid.dxf", "p-lid_2.dxf", "p-LID_3.dxf", "p-Lid_2_2.dxf"]);
|
||
}
|