From d7a4a29ce32d3edca1db991294170f7961bd16b0 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:34:06 +0200 Subject: [PATCH] Flat-pattern DXF for a laser shop: layers by role, dashed bend lines, bend notes, a file per body (#1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1284. sheet.export_dxf writes layers by role (OUTLINE, CUT, TEXT or ETCH, dashed BEND_UP/BEND_DOWN, optional BEND_NOTES, POCKET__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. --- crates/doc/src/eval_more.rs | 35 ++- crates/doc/src/sheet.rs | 87 +++++++ crates/engine/src/cmd/sheet.rs | 246 +++++++++++++++--- crates/engine/src/cmd/sheet_dxf_tests.rs | 307 ++++++++++++++++++++++- crates/engine/src/cmd/sheet_tests.rs | 8 +- crates/io/src/dxf_out.rs | 205 ++++++++++++++- crates/io/src/lib.rs | 2 +- docs/book/src/guide/sheet-metal.md | 55 +++- 8 files changed, 887 insertions(+), 58 deletions(-) diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 497d3b0b..bd0526e7 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -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 = 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> { 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::>>()? } 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())))); diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 4b577ebb..02e44e6b 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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, + /// 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, /// 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> { 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)>, +} + +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 { 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 = 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 = 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. diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 74740c15..396e2f9f 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -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__Z_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, `-.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__Z_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 { - 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, +} + +/// 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 { + 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 { }; 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 { .into_iter() .flatten() .collect(); - if !perforation.is_empty() { - layers.insert("PERFORATION".to_string()); - } - let layers: Vec = 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 = 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 { 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 { } 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 = ["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 { - 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 { 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 `-` 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 { + 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 = 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 { + 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::>>()?; + let paths = path.map(|path| body_paths(path, &sheets.iter().map(|sh| sh.body.clone()).collect::>())); + 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()})); } diff --git a/crates/engine/src/cmd/sheet_dxf_tests.rs b/crates/engine/src/cmd/sheet_dxf_tests.rs index 993532cb..992bb8c9 100644 --- a/crates/engine/src/cmd/sheet_dxf_tests.rs +++ b/crates/engine/src/cmd/sheet_dxf_tests.rs @@ -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 { + 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::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::>(); + // 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::>()); + // 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::>()); + // 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::>(); + 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 = 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::>()) + }; + // 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::>(), ["Lid", "Side Wall"]); + assert_eq!(files.iter().map(|f| f["bends"].as_u64()).collect::>(), [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 { + super::body_paths(path, &bodies.iter().map(|b| b.to_string()).collect::>()).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"]); } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 1df83c6e..ccf19dbe 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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")); } diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index 161a80c2..46569895 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -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, 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, weights: Option>, }, + /// 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> { + let start = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, table)).unwrap(); + let mut out: Vec> = 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::>(), ["ByBlock", "ByLayer", "Continuous", "DASHED"]); + let dashed = &types[3]; + let numbers = |code: i32| dashed.iter().filter(|(c, _)| *c == code).map(|(_, v)| v.parse::().unwrap()).collect::>(); + 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::>(), + [("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::>(), ["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())]); + } } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 4ab20441..6ef6f575 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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}; diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 9b685040..45f2480e 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -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 `-.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