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
@@ -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, ®ions, *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, ®ions, *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(®ion), interval.0, interval.1)?;
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false)
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), 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()))));
|
||||
|
||||
@@ -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(¬ch, &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
@@ -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"));
|
||||
}
|
||||
|
||||
+196
-9
@@ -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())]);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user