Flat-pattern DXF for a laser shop: layers by role, dashed bend lines, bend notes, a file per body (#1324)
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.
This commit is contained in:
1 parent
9fbbf8dc3a
commit
d7a4a29ce3
8 files changed
+887
-58
No files matched your search
+214
-32
@@ -64,7 +64,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.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, and pockets on POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM"),
|
||||
.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), \
|
||||
text?: cut|etch (default cut: letters that text cut through the sheet are cut contours on layer TEXT; etch: single contours on layer ETCH and the sheet keeps its material there; text machined to a depth is always on ETCH), \
|
||||
bend_notes?: bool (default false: a note beside each bend line on layer BEND_NOTES, `B1 UP 90%%d R1`), note_height?: mm (default 3), \
|
||||
per_body?: bool (a file for every sheet body, `<path>-<body>.dxf`, or their DXF texts without a path → files: [{body, path | dxf, bends}]; not with `body`) \
|
||||
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
|
||||
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
|
||||
),
|
||||
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"),
|
||||
@@ -506,12 +513,79 @@ pub enum DxfCurves {
|
||||
Arcs,
|
||||
}
|
||||
|
||||
/// A DXF (AutoCAD 2000, mm) of the flat pattern on named layers: OUTLINE, BEND_UP and BEND_DOWN
|
||||
/// (centre lines), and one layer per pocket depth. Free-form curves as `curves` says.
|
||||
pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, DxfShape};
|
||||
/// Where the letters that text cut through the sheet go in the flat DXF.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DxfText {
|
||||
/// Cut contours on layer TEXT: the sheet has holes in the shape of the letters.
|
||||
#[default]
|
||||
Cut,
|
||||
/// Single contours on layer ETCH: the sheet keeps its material there.
|
||||
Etch,
|
||||
}
|
||||
|
||||
/// What `sheet.export_dxf` is asked for.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct DxfOptions {
|
||||
pub curves: DxfCurves,
|
||||
pub text: DxfText,
|
||||
/// The height (mm) of the note beside each bend line, when it is to have one.
|
||||
pub bend_notes: Option<f64>,
|
||||
}
|
||||
|
||||
/// The layer of a role: its AutoCAD colour index (1 red, 2 yellow, 3 green, 4 cyan, 5 blue,
|
||||
/// 6 magenta, 7 white or black, 8 grey) and, for the bend lines, a dashed line. The pocket layers
|
||||
/// and PERFORATION are green.
|
||||
fn dxf_layer(name: &str) -> solidcraft_io::DxfLayer {
|
||||
use solidcraft_io::DxfLayer;
|
||||
match name {
|
||||
"OUTLINE" => DxfLayer::new(name, 7),
|
||||
"CUT" => DxfLayer::new(name, 4),
|
||||
"TEXT" => DxfLayer::new(name, 2),
|
||||
"ETCH" => DxfLayer::new(name, 6),
|
||||
"BEND_UP" => DxfLayer::new(name, 1).dashed(),
|
||||
"BEND_DOWN" => DxfLayer::new(name, 5).dashed(),
|
||||
"BEND_NOTES" => DxfLayer::new(name, 8),
|
||||
_ => DxfLayer::new(name, 3),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number in a bend note: at most three decimals, none when they are zeros (`90`, `2.5`).
|
||||
fn compact(v: f64) -> String {
|
||||
let t = format!("{v:.3}");
|
||||
let t = t.trim_end_matches('0').trim_end_matches('.');
|
||||
if t.is_empty() || t == "-0" { "0".into() } else { t.into() }
|
||||
}
|
||||
|
||||
/// The note beside a bend line, `B1 UP 90%%d R2.5` (number, direction, angle, inner radius): as
|
||||
/// high as `height`, centred half a height beside the middle of the line, parallel to it and
|
||||
/// readable from below or from the right. (`%%d` is AutoCAD's degree sign.)
|
||||
fn bend_note(b: &solidcraft_doc::sheet::BendInfo, height: f64) -> solidcraft_io::DxfShape {
|
||||
let along = (b.end - b.start).normalized().unwrap_or(Vec2::X);
|
||||
let mut angle = along.angle().to_degrees();
|
||||
if angle > 90.0 + 1e-9 {
|
||||
angle -= 180.0;
|
||||
} else if angle <= -90.0 + 1e-9 {
|
||||
angle += 180.0;
|
||||
}
|
||||
let beside = Vec2::from_angle(angle.to_radians()).perp();
|
||||
solidcraft_io::DxfShape::Text {
|
||||
at: (b.start + b.end) * 0.5 + beside * (height * 0.5),
|
||||
height,
|
||||
angle_deg: angle,
|
||||
text: format!("B{} {} {}%%d R{}", b.index, if b.up { "UP" } else { "DOWN" }, compact(b.angle_deg), compact(b.radius)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A DXF (AutoCAD 2000, mm) of the flat pattern on layers by role: OUTLINE (the outer contour of
|
||||
/// each piece), CUT (what is cut out inside it, and the loose pieces of a cut), TEXT or ETCH
|
||||
/// (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES (when asked for), one layer
|
||||
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
|
||||
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
|
||||
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
|
||||
use solidcraft_io::{DxfEntity, DxfShape};
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let letters = sh.letters();
|
||||
let pocket_layer = |p: &solidcraft_doc::sheet::SheetPocket| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"BOTTOM"
|
||||
@@ -522,10 +596,6 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
};
|
||||
format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1)
|
||||
};
|
||||
let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]);
|
||||
for p in &pockets {
|
||||
layers.insert(pocket_layer(p));
|
||||
}
|
||||
let perforation: Vec<(Vec2, f64)> = sh
|
||||
.perforations
|
||||
.iter()
|
||||
@@ -534,29 +604,54 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
if !perforation.is_empty() {
|
||||
layers.insert("PERFORATION".to_string());
|
||||
}
|
||||
let layers: Vec<DxfLayer> = layers
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let color = match name.as_str() {
|
||||
"OUTLINE" => 7,
|
||||
"BEND_UP" => 1,
|
||||
"BEND_DOWN" => 5,
|
||||
_ => 3,
|
||||
};
|
||||
DxfLayer { name, color }
|
||||
// A piece inside another piece's hole (a letter's counter, the middle of a ring cut) is no
|
||||
// outline of the sheet but a loose piece the cut leaves.
|
||||
let nested: Vec<bool> = regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, r)| {
|
||||
let probe = r.outer.segs.first().map(solidcraft_geom::Seg2::mid);
|
||||
probe.is_some_and(|p| regions.iter().enumerate().any(|(i, other)| i != j && other.outer.contains(p)))
|
||||
})
|
||||
.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)));
|
||||
// The loops, in the order they are written: inner contours, markings, pockets, the outline.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Pass {
|
||||
Cut,
|
||||
Etch,
|
||||
Pocket,
|
||||
Outline,
|
||||
}
|
||||
let mut loops: Vec<(Pass, &solidcraft_geom::Loop2, String)> = Vec::new();
|
||||
for (j, r) in regions.iter().enumerate() {
|
||||
for (k, lp) in std::iter::once(&r.outer).chain(&r.holes).enumerate() {
|
||||
let (pass, layer) = if k == 0 && !nested.get(j).copied().unwrap_or(false) {
|
||||
(Pass::Outline, "OUTLINE")
|
||||
} else if letters.is_letter(lp) {
|
||||
match o.text {
|
||||
DxfText::Cut => (Pass::Cut, "TEXT"),
|
||||
DxfText::Etch => (Pass::Etch, "ETCH"),
|
||||
}
|
||||
} else {
|
||||
(Pass::Cut, "CUT")
|
||||
};
|
||||
loops.push((pass, lp, layer.to_string()));
|
||||
}
|
||||
}
|
||||
for p in &pockets {
|
||||
let layer = pocket_layer(p);
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
// Text machined to a depth is a marking, whatever its depth.
|
||||
if letters.is_letter(lp) {
|
||||
loops.push((Pass::Etch, lp, "ETCH".to_string()));
|
||||
} else {
|
||||
loops.push((Pass::Pocket, lp, layer.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
loops.sort_by_key(|(pass, ..)| *pass);
|
||||
let mut entities = Vec::new();
|
||||
for (h, layer) in loops {
|
||||
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) {
|
||||
@@ -564,7 +659,7 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
continue;
|
||||
}
|
||||
let fitted;
|
||||
let h = match curves {
|
||||
let h = match o.curves {
|
||||
DxfCurves::Arcs if !h.is_line_arc() => {
|
||||
fitted = h
|
||||
.fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
|
||||
@@ -578,23 +673,110 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
}
|
||||
for b in sh.bends() {
|
||||
entities.push(DxfEntity { layer: if b.up { "BEND_UP" } else { "BEND_DOWN" }.into(), shape: DxfShape::Line(b.start, b.end) });
|
||||
if let Some(height) = o.bend_notes {
|
||||
entities.push(DxfEntity { layer: "BEND_NOTES".into(), shape: bend_note(&b, height) });
|
||||
}
|
||||
}
|
||||
for (c, r) in perforation {
|
||||
entities.push(DxfEntity { layer: "PERFORATION".into(), shape: DxfShape::Circle { c, r } });
|
||||
}
|
||||
// The four layers of every flat pattern, and those the rest is on.
|
||||
let mut names: std::collections::BTreeSet<String> = ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"].map(String::from).into();
|
||||
names.extend(entities.iter().map(|e| e.layer.clone()));
|
||||
let layers: Vec<_> = names.iter().map(|name| dxf_layer(name)).collect();
|
||||
Ok(solidcraft_io::write_dxf_drawing(&layers, &entities))
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_dxf";
|
||||
/// What a `sheet.export_dxf` call asks for; a value it cannot use is refused.
|
||||
fn dxf_options(p: &Value, cmd: &str) -> Result<DxfOptions> {
|
||||
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 text = match p.get("text").filter(|v| !v.is_null()) {
|
||||
None => DxfText::Cut,
|
||||
Some(v) => match v.as_str().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cut") => DxfText::Cut,
|
||||
Some("etch") => DxfText::Etch,
|
||||
_ => return Err(bad(cmd, "`text` must be cut or etch")),
|
||||
},
|
||||
};
|
||||
let height = match p.get("note_height").filter(|v| !v.is_null()) {
|
||||
None => 3.0,
|
||||
Some(v) => {
|
||||
v.as_f64().filter(|h| (0.1..=100.0).contains(h)).ok_or_else(|| bad(cmd, "`note_height` must be a number of mm from 0.1 to 100"))?
|
||||
}
|
||||
};
|
||||
let bend_notes = match p.get("bend_notes").filter(|v| !v.is_null()) {
|
||||
None | Some(Value::Bool(false)) => None,
|
||||
Some(Value::Bool(true)) => Some(height),
|
||||
Some(_) => return Err(bad(cmd, "`bend_notes` must be true or false")),
|
||||
};
|
||||
Ok(DxfOptions { curves, text, bend_notes })
|
||||
}
|
||||
|
||||
/// The files of an export of every sheet body: `path` with `-<body>` before its extension
|
||||
/// (`.dxf` when it has none). Characters a file name cannot hold become `_`, and a name another
|
||||
/// body's file already has (in any case) gets `_2`, `_3`…
|
||||
fn body_paths(path: &str, bodies: &[String]) -> Vec<String> {
|
||||
let p = std::path::Path::new(path);
|
||||
let stem = p.file_stem().map_or_else(|| "flat".to_string(), |s| s.to_string_lossy().to_string());
|
||||
let ext = p.extension().map_or_else(|| "dxf".to_string(), |e| e.to_string_lossy().to_string());
|
||||
let mut taken: Vec<String> = Vec::new();
|
||||
bodies
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let clean: String = body.chars().map(|c| if c.is_control() || "<>:\"/\\|?*".contains(c) { '_' } else { c }).collect();
|
||||
let clean = clean.trim().trim_end_matches(['.', ' ']);
|
||||
let base = if clean.is_empty() { "body" } else { clean };
|
||||
let mut name = base.to_string();
|
||||
let mut n = 2;
|
||||
while taken.contains(&name.to_lowercase()) {
|
||||
name = format!("{base}_{n}");
|
||||
n += 1;
|
||||
}
|
||||
taken.push(name.to_lowercase());
|
||||
p.with_file_name(format!("{stem}-{name}.{ext}")).to_string_lossy().to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_dxf";
|
||||
let options = dxf_options(p, cmd)?;
|
||||
let per_body = match p.get("per_body").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`per_body` must be true or false")),
|
||||
};
|
||||
let path = str_(p, "path").filter(|x| !x.trim().is_empty());
|
||||
if per_body {
|
||||
if p.get("body").is_some_and(|b| !b.is_null()) {
|
||||
return Err(bad(cmd, "`per_body` writes every sheet body: leave `body` out"));
|
||||
}
|
||||
let sheets = &s.model.state().sheets;
|
||||
if sheets.is_empty() {
|
||||
return Err(bad(cmd, "there is no sheet metal body"));
|
||||
}
|
||||
// Every file is made before the first is written.
|
||||
let texts = sheets.iter().map(|sh| flat_dxf(sh, &options)).collect::<Result<Vec<_>>>()?;
|
||||
let paths = path.map(|path| body_paths(path, &sheets.iter().map(|sh| sh.body.clone()).collect::<Vec<_>>()));
|
||||
let mut files = Vec::new();
|
||||
for (i, (sh, text)) in sheets.iter().zip(&texts).enumerate() {
|
||||
let bends = sh.bends().len();
|
||||
if let Some(file) = paths.as_ref().and_then(|paths| paths.get(i)) {
|
||||
solidcraft_io::vfs::write(file, text.as_bytes()).map_err(|e| EngineError::Other(format!("{file}: {e}")))?;
|
||||
files.push(json!({"body": sh.body, "path": file, "bends": bends}));
|
||||
} else {
|
||||
files.push(json!({"body": sh.body, "dxf": text, "bends": bends}));
|
||||
}
|
||||
}
|
||||
return Ok(json!({"files": files}));
|
||||
}
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
let text = flat_dxf(&sh, curves)?;
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
let text = flat_dxf(&sh, &options)?;
|
||||
if let Some(path) = path {
|
||||
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()}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities.
|
||||
//! 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};
|
||||
|
||||
@@ -16,11 +18,18 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
|
||||
/// 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();
|
||||
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());
|
||||
@@ -30,6 +39,31 @@ fn plate_with(curves: &[(&str, Value)]) -> Session {
|
||||
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()
|
||||
}
|
||||
@@ -217,5 +251,272 @@ fn bend_lines_and_layers_are_still_there() {
|
||||
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"));
|
||||
// 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"]);
|
||||
}
|
||||
@@ -1551,7 +1551,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, super::DxfCurves::Exact).unwrap();
|
||||
let dxf = super::flat_dxf(&sh, &super::DxfOptions::default()).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();
|
||||
@@ -1563,7 +1563,7 @@ 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, super::DxfCurves::Exact).unwrap();
|
||||
let dxf = super::flat_dxf(&sh, &super::DxfOptions::default()).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.0\n 51\n180.0\n"), "{dxf}");
|
||||
@@ -2001,7 +2001,9 @@ fn dxf_outline_only(text: &str) -> String {
|
||||
for end in 1..=pairs.len() {
|
||||
if end == pairs.len() || pairs.get(end).is_some_and(|p| p.0 == "0") {
|
||||
let entity = &pairs[start..end];
|
||||
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE")) && entity.iter().any(|p| p.0 == "8" && p.1 == "OUTLINE") {
|
||||
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE"))
|
||||
&& entity.iter().any(|p| p.0 == "8" && matches!(p.1, "OUTLINE" | "CUT"))
|
||||
{
|
||||
for (code, value) in entity {
|
||||
out.push_str(&format!("{code}\n{value}\n"));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user