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:
NOWN-DEV authored and GitHub committed 2026-10-15 06:34:06 -04:00
1 parent 9fbbf8dc3a
commit d7a4a29ce3
8 files changed
+887 -58

No files matched your search

+33 -2
View File
@@ -768,7 +768,16 @@ pub(super) fn sheet_cut(
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
// The outlines of the sketch's text: the sheet remembers where its letters went.
let text: Vec<Seg2> = ss
.sketch
.links
.iter()
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
.flat_map(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text)
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
@@ -824,7 +833,7 @@ pub(super) fn sheet_primitive_cut(
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false, &[])
}
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
@@ -911,6 +920,12 @@ impl PrimitivePrism {
}
}
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
const MAX_TEXT_CURVES: usize = 20_000;
const MAX_LABELS: usize = 1000;
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
@@ -922,6 +937,7 @@ fn sheet_prism_cut(
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
text: &[Seg2],
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
@@ -960,6 +976,12 @@ fn sheet_prism_cut(
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
.then_some(c.interval)
});
// The regions of the cut that are letters, with their counters.
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
} else {
Vec::new()
};
let mut plans = Vec::new();
'sheets: for original in &st.sheets {
if !sheet_eligible(f, st, targets, original) {
@@ -993,6 +1015,7 @@ fn sheet_prism_cut(
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
let mut labels = Vec::new();
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
@@ -1032,6 +1055,7 @@ fn sheet_prism_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
@@ -1096,6 +1120,13 @@ fn sheet_prism_cut(
}
}
}
if modeled {
for flat in labels {
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
sh.labels.push(flat);
}
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
+87
View File
@@ -490,6 +490,12 @@ pub struct SheetBody {
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perforations: Vec<SheetPerforation>,
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Region2>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -1600,6 +1606,26 @@ impl SheetBody {
Ok(through.unwrap_or_default())
}
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
/// loop of the flat pattern is a letter.
pub fn letters(&self) -> Letters {
let regions = self
.labels
.iter()
.map(|r| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in r.outer.polyline(1e-3) {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
let pad = Vec2::new(0.01, 0.01);
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
})
.collect();
Letters { regions }
}
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
let material = self.flat_regions()?;
@@ -3953,6 +3979,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
const TOL: f64 = 1e-4;
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
}
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
#[derive(Clone, Debug)]
pub struct Letters {
/// Each text region's corners (lowest, highest) and the segments of its outlines.
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
}
impl Letters {
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
/// counters.
pub fn is_letter(&self, lp: &Loop2) -> bool {
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
@@ -3974,6 +4024,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4031,6 +4082,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4175,6 +4227,41 @@ mod tests {
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
}
#[test]
fn loops_lie_on_the_curves_they_are_made_of() {
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
let curves: Vec<Seg2> = circle.segs.clone();
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
assert!(loop_lies_on(&circle, &curves));
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
// Another circle, a square and an empty loop do not.
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
// A loop only partly on them (a letter across the sheet's edge) is not on them.
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
assert!(!loop_lies_on(&notch, &curves));
}
#[test]
fn a_sheet_knows_the_letters_it_carries() {
// A recorded text region with a counter: its outline and its counter are letters, the same
// circle elsewhere, a loop near it that is another size and a loop far away are not.
let mut s = plate(100.0, 60.0);
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
let letters = s.letters();
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
assert!(letters.is_letter(&counter.reversed()));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
// A sheet that carries none has no letters.
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
}
#[test]
fn edge_flange_matches_fusion() {
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
+214 -32
View File
@@ -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()}));
}
+304 -3
View File
@@ -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"]);
}
+5 -3
View File
@@ -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"));
}
+196 -9
View File
@@ -7,18 +7,50 @@
//! 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.
//! root dictionary. Layers can be dashed and a drawing can carry TEXT (for annotations). 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).
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfLinetype {
#[default]
Continuous,
/// AutoCAD's DASHED: a dash of 12.7 mm and a gap of 6.35 mm.
Dashed,
}
impl DxfLinetype {
fn name(self) -> &'static str {
match self {
DxfLinetype::Continuous => "Continuous",
DxfLinetype::Dashed => "DASHED",
}
}
}
/// A layer: its name, AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black) and linetype.
#[derive(Clone, Debug, PartialEq)]
pub struct DxfLayer {
pub name: String,
pub color: i16,
pub linetype: DxfLinetype,
}
impl DxfLayer {
/// A continuous layer.
pub fn new(name: impl Into<String>, color: i16) -> DxfLayer {
DxfLayer { name: name.into(), color, linetype: DxfLinetype::Continuous }
}
/// The same layer with a dashed line.
pub fn dashed(self) -> DxfLayer {
DxfLayer { linetype: DxfLinetype::Dashed, ..self }
}
}
/// The geometry of one entity (millimetres).
@@ -54,6 +86,15 @@ pub enum DxfShape {
ctrl: Vec<Vec2>,
weights: Option<Vec<f64>>,
},
/// One line of text, centred on `at` (its baseline) and turned `angle_deg` counter-clockwise.
/// `%%d`, `%%p` and `%%c` are AutoCAD's degree, plus-minus and diameter signs; other letters
/// outside ASCII are written as `\U+XXXX`.
Text {
at: Vec2,
height: f64,
angle_deg: f64,
text: String,
},
}
/// An entity on a layer.
@@ -180,6 +221,13 @@ impl DxfShape {
}
// A spline lies within the hull of its control points.
DxfShape::Spline { ctrl, .. } => out.extend(ctrl.iter().copied()),
// Where a viewer puts the letters depends on its font: 0.8 heights for each.
DxfShape::Text { at, height, angle_deg, text } => {
let (along, up) = (Vec2::from_angle(angle_deg.to_radians()), Vec2::from_angle(angle_deg.to_radians()).perp());
let half = 0.4 * height * text.chars().count() as f64;
out.extend([-half, half].map(|s| *at + along * s));
out.extend([-half, half].map(|s| *at + along * s + up * *height));
}
}
}
}
@@ -256,6 +304,19 @@ impl Out {
}
}
/// A text on one line, as a TEXT entity holds it: at most 250 characters, control characters as
/// spaces, letters outside ASCII as `\U+XXXX` (`?` beyond the first 65536).
fn text_value(s: &str) -> String {
s.chars()
.take(250)
.map(|c| match c {
c if c.is_ascii_control() => " ".to_string(),
c if c.is_ascii() => c.to_string(),
c => u16::try_from(u32::from(c)).map_or_else(|_| "?".to_string(), |u| format!("\\U+{u:04X}")),
})
.collect()
}
/// 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 {
@@ -271,7 +332,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
})
.unwrap_or((Vec2::ZERO, Vec2::ZERO));
let mut layer_list = vec![DxfLayer { name: "0".into(), color: 7 }];
let mut layer_list = vec![DxfLayer::new("0", 7)];
for l in layers {
if !layer_list.iter().any(|x| x.name == l.name) {
layer_list.push(l.clone());
@@ -285,15 +346,27 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.code(0, "SECTION");
o.code(2, "TABLES");
let t = o.table("LTYPE", 3);
for (name, description) in [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")] {
// The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths
// (a gap is negative) after the pattern's total.
let linetypes: [(&str, &str, &[f64]); 4] = [
("ByBlock", "", &[]),
("ByLayer", "", &[]),
("Continuous", "Solid line", &[]),
("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]),
];
let t = o.table("LTYPE", linetypes.len());
for (name, description, pattern) in linetypes {
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(73, pattern.len());
o.num(40, pattern.iter().map(|d| d.abs()).sum());
for d in pattern {
o.num(49, *d);
o.code(74, 0);
}
}
o.code(0, "ENDTAB");
let t = o.table("LAYER", layer_list.len());
@@ -302,9 +375,22 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.code(2, &l.name);
o.code(70, 0);
o.code(62, l.color);
o.code(6, "Continuous");
o.code(6, l.linetype.name());
}
o.code(0, "ENDTAB");
// The one text style, which a TEXT entity uses when it names none.
let t = o.table("STYLE", 1);
o.record("STYLE", "AcDbTextStyleTableRecord", &t);
o.code(2, "Standard");
o.code(70, 0);
o.num(40, 0.0);
o.num(41, 1.0);
o.num(50, 0.0);
o.code(71, 0);
o.num(42, 2.5);
o.code(3, "txt");
o.code(4, "");
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");
@@ -332,6 +418,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
DxfShape::Spline { .. } => "SPLINE",
DxfShape::Text { .. } => "TEXT",
};
let h = o.handle();
o.code(0, kind);
@@ -391,6 +478,19 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.point(10, *p);
}
}
DxfShape::Text { at, height, angle_deg, text } => {
o.code(100, "AcDbText");
o.point(10, *at);
o.num(40, *height);
o.code(1, text_value(text));
o.num(50, *angle_deg);
// Centred (72 = 1): a reader that places the text by the second alignment point
// and one that takes the first both put its middle on `at`.
o.code(72, 1);
o.point(11, *at);
o.code(100, "AcDbText");
o.code(73, 0);
}
}
}
o.code(0, "ENDSEC");
@@ -561,7 +661,7 @@ mod tests {
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 dxf = write_dxf_drawing(&[DxfLayer::new("OUTLINE", 7), DxfLayer::new("CUT", 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()));
@@ -667,4 +767,91 @@ mod tests {
let last = |code: i32| e.1.iter().rposition(|(c, _)| *c == code).unwrap();
assert!(last(40) < at(41) && last(41) < at(10), "{:?}", e.1);
}
/// The records of a symbol table, each as its group codes after the type.
fn table_records(p: &[(i32, String)], table: &str) -> Vec<Vec<(i32, String)>> {
let start = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, table)).unwrap();
let mut out: Vec<Vec<(i32, String)>> = Vec::new();
for (c, v) in &p[start + 1..] {
match (*c, v.as_str()) {
(0, "ENDTAB") => break,
(0, _) => out.push(Vec::new()),
_ => {
if let Some(record) = out.last_mut() {
record.push((*c, v.clone()));
}
}
}
}
out
}
fn field(record: &[(i32, String)], code: i32) -> &str {
record.iter().find(|(c, _)| *c == code).map_or("", |(_, v)| v.as_str())
}
#[test]
fn a_dashed_layer_names_its_linetype_and_the_table_defines_it() {
let dxf = write_dxf_drawing(&[DxfLayer::new("BEND", 1).dashed(), DxfLayer::new("CUT", 4)], &[]);
let p = pairs(&dxf);
// The linetypes of every drawing, then DASHED: a dash of 12.7 mm and a gap of 6.35 mm, 19.05 mm in all.
let types = table_records(&p, "LTYPE");
assert_eq!(types.iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["ByBlock", "ByLayer", "Continuous", "DASHED"]);
let dashed = &types[3];
let numbers = |code: i32| dashed.iter().filter(|(c, _)| *c == code).map(|(_, v)| v.parse::<f64>().unwrap()).collect::<Vec<_>>();
assert!(near(&numbers(49), &[12.7, -6.35]) && near(&numbers(40), &[19.05]) && near(&numbers(73), &[2.0]) && near(&numbers(72), &[65.0]));
// The table says how many records it holds.
let at = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "LTYPE")).unwrap();
assert_eq!(p[at..].iter().find(|(c, _)| *c == 70).map(|(_, v)| v.as_str()), Some("4"));
// Each layer names its linetype.
let layers = table_records(&p, "LAYER");
assert_eq!(
layers.iter().map(|r| (field(r, 2), field(r, 62), field(r, 6))).collect::<Vec<_>>(),
[("0", "7", "Continuous"), ("BEND", "1", "DASHED"), ("CUT", "4", "Continuous")]
);
}
#[test]
fn text_is_centred_on_its_point_and_the_reader_gets_it_back() {
let at = Vec2::new(30.0, 12.0);
let note = DxfShape::Text { at, height: 3.0, angle_deg: 90.0, text: "AB%%d\nC\u{b0}".into() };
let dxf = write_dxf_drawing(&[DxfLayer::new("NOTES", 8)], &[DxfEntity { layer: "NOTES".into(), shape: note }]);
let file = entities(&dxf);
let [text] = file.as_slice() else { panic!("{file:?}") };
assert_eq!(text.0, "TEXT");
// One line (the newline is a space), the sign outside ASCII a code of its own, `%%d` AutoCAD's degree sign.
assert_eq!(text.1.iter().find(|(c, _)| *c == 1).map(|(_, v)| v.as_str()), Some("AB%%d C\\U+00B0"));
// Centred (72 = 1) on the second alignment point, which is the first as well; 3 mm high, turned 90°.
assert!(
near(&values(text, 11), &[30.0])
&& near(&values(text, 21), &[12.0])
&& near(&values(text, 10), &[30.0])
&& near(&values(text, 20), &[12.0])
);
assert!(
near(&values(text, 72), &[1.0]) && near(&values(text, 73), &[0.0]) && near(&values(text, 40), &[3.0]) && near(&values(text, 50), &[90.0])
);
// Its style is in the STYLE table, and the layer it is on in the LAYER table.
let p = pairs(&dxf);
assert_eq!(table_records(&p, "STYLE").iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["Standard"]);
assert!(text.1.contains(&(8, "NOTES".to_string())));
// The reader finds the text where it was put, as high and as turned.
let back = read_dxf(&dxf).unwrap();
assert!(
matches!(&back[..], [Geom2::Text { at: found, height, angle, .. }] if found.dist(at) < 1e-9 && (height - 3.0).abs() < 1e-9 && (angle - FRAC_PI_2).abs() < 1e-9),
"{back:?}"
);
}
#[test]
fn the_extents_hold_the_text() {
// Five letters of 3 mm, turned to read upwards: 0.8 × 3 × 5 = 12 mm long around y = 12, and
// 3 mm to the left of the baseline at x = 30.
let shape = DxfShape::Text { at: Vec2::new(30.0, 12.0), height: 3.0, angle_deg: 90.0, text: "ABCDE".into() };
let dxf = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]);
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("$EXTMIN")[..2], &[(10, "27.0".to_string()), (20, "6.0".to_string())]);
assert_eq!(&after("$EXTMAX")[..2], &[(10, "30.0".to_string()), (20, "18.0".to_string())]);
}
}
+1 -1
View File
@@ -26,7 +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 dxf_out::{DxfEntity, DxfLayer, DxfLinetype, 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};
+47 -8
View File
@@ -185,13 +185,51 @@ consume an undo step.
{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}}
```
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.
The DXF is an AutoCAD 2000 (`AC1015`) drawing in millimetres, made for a laser-cutting shop's
nesting or CAM software: each kind of line is on a layer of its own, with a colour (AutoCAD's
index) and, for the bends, a linetype that the file defines.
| Layer | Colour | Line | Holds |
|---|---|---|---|
| `OUTLINE` | 7 white or black | continuous | the outer contour of each piece of the sheet |
| `CUT` | 4 cyan | continuous | what is cut out inside it: holes, slots, and the loose pieces a cut leaves (the middle of a ring) |
| `TEXT` | 2 yellow | continuous | the letters that text cut through the sheet, with their counters; only with `"text": "cut"`, the default |
| `ETCH` | 6 magenta | continuous | letters as single contours, to be marked and not cut: `"text": "etch"`, and always text machined to a depth |
| `BEND_UP`, `BEND_DOWN` | 1 red, 5 blue | `DASHED` | the bend centre lines, by direction |
| `BEND_NOTES` | 8 grey | continuous | the note beside each bend line, only with `"bend_notes": true` |
| `POCKET_…` | 3 green | continuous | pockets, a layer for each depth (see below) |
| `PERFORATION` | 3 green | continuous | the holes of a perforation field |
`OUTLINE`, `CUT`, `BEND_UP` and `BEND_DOWN` are always in the layer table, the others when the
sheet has something for them. The inner contours (`CUT`, `TEXT`) are written before the outline,
so a program that follows the file's order cuts inside-out.
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.
Letters are told from other cut-outs by where they came from: a cut whose sketch holds text
(Sketch Text) marks the cut-outs it makes as letters, counters included, while the design is
built; nothing more is saved in the design file. A letter that another cut-out overlaps, or
that the sheet's edge clips, has become one shape with it and stays on `CUT`.
```json
{"command":"sheet.export_dxf","params":{"path":"tray-flat.dxf","text":"etch","bend_notes":true}}
```
With `"bend_notes": true` each bend line gets a note on `BEND_NOTES`, `B1 UP 90° R1` (the bend's
number, its direction, its angle and its inner radius; `%%d` is AutoCAD's degree sign). It is 3 mm
high (`"note_height"` changes that), centred half a height beside the middle of the line,
parallel to it and readable from below or from the right. A bend that cut-outs split has a note
for each piece. Notes are off by default, because a program that cuts or marks every layer would
mark them.
A design with several sheet bodies exports one file for each with `"per_body": true`: the files
are `<path>-<body>.dxf` (characters a file name cannot hold become `_`; two bodies that would
share a name get `_2`, `_3`…), and the reply lists them in `files`. Without a `path` it lists
their DXF texts instead.
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.
@@ -225,7 +263,8 @@ become a through hole wherever they remove the whole thickness. Flat-pattern JSO
`pockets` with their side, depth and bottom/top thickness coordinates in millimetres.
DXF pocket boundaries use `POCKET_BOTTOM_Z…_TO_…_MM`, `POCKET_TOP_Z…_TO_…_MM`, or
`POCKET_INTERNAL_Z…_TO_…_MM` layers. These are machining operations, so do not treat every
pocket layer as a through-cut contour when importing the drawing into a CAM tool.
pocket layer as a through-cut contour when importing the drawing into a CAM tool. (Text
machined to a depth is a marking, on `ETCH`.)
Extrude Intersect on a straight base or an unfolded sheet retains the material inside both
the current tool footprint and its depth interval, including interior holes, detached islands