Flat-pattern DXF for a laser shop: layers by role, dashed bend lines, bend notes, a file per body (#1324)

Closes #1284. sheet.export_dxf writes layers by role (OUTLINE, CUT, TEXT or ETCH, dashed BEND_UP/BEND_DOWN, optional BEND_NOTES, POCKET_<side>_Z…_MM and PERFORATION), inner contours before the outline; text: cut|etch, bend_notes and note_height, per_body files. Letters are derived from the timeline, nothing new is saved (format unchanged). I merged main in (the export's parameter list keeps main's pocket layer names). Checked on main + this branch: cargo check --workspace, the engine cmd::sheet tests (160) and the io DXF tests.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-15 06:34:06 -04:00
1 parent 9fbbf8dc3a
commit d7a4a29ce3
8 files changed
+887 -58

No files matched your search

+33 -2
View File
@@ -768,7 +768,16 @@ pub(super) fn sheet_cut(
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
// The outlines of the sketch's text: the sheet remembers where its letters went.
let text: Vec<Seg2> = ss
.sketch
.links
.iter()
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
.flat_map(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text)
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
@@ -824,7 +833,7 @@ pub(super) fn sheet_primitive_cut(
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false, &[])
}
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
@@ -911,6 +920,12 @@ impl PrimitivePrism {
}
}
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
const MAX_TEXT_CURVES: usize = 20_000;
const MAX_LABELS: usize = 1000;
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
@@ -922,6 +937,7 @@ fn sheet_prism_cut(
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
text: &[Seg2],
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
@@ -960,6 +976,12 @@ fn sheet_prism_cut(
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
.then_some(c.interval)
});
// The regions of the cut that are letters, with their counters.
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
} else {
Vec::new()
};
let mut plans = Vec::new();
'sheets: for original in &st.sheets {
if !sheet_eligible(f, st, targets, original) {
@@ -993,6 +1015,7 @@ fn sheet_prism_cut(
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
let mut labels = Vec::new();
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
@@ -1032,6 +1055,7 @@ fn sheet_prism_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
@@ -1096,6 +1120,13 @@ fn sheet_prism_cut(
}
}
}
if modeled {
for flat in labels {
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
sh.labels.push(flat);
}
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
+87
View File
@@ -490,6 +490,12 @@ pub struct SheetBody {
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perforations: Vec<SheetPerforation>,
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Region2>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -1600,6 +1606,26 @@ impl SheetBody {
Ok(through.unwrap_or_default())
}
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
/// loop of the flat pattern is a letter.
pub fn letters(&self) -> Letters {
let regions = self
.labels
.iter()
.map(|r| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in r.outer.polyline(1e-3) {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
let pad = Vec2::new(0.01, 0.01);
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
})
.collect();
Letters { regions }
}
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
let material = self.flat_regions()?;
@@ -3953,6 +3979,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
const TOL: f64 = 1e-4;
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
}
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
#[derive(Clone, Debug)]
pub struct Letters {
/// Each text region's corners (lowest, highest) and the segments of its outlines.
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
}
impl Letters {
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
/// counters.
pub fn is_letter(&self, lp: &Loop2) -> bool {
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
@@ -3974,6 +4024,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4031,6 +4082,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4175,6 +4227,41 @@ mod tests {
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
}
#[test]
fn loops_lie_on_the_curves_they_are_made_of() {
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
let curves: Vec<Seg2> = circle.segs.clone();
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
assert!(loop_lies_on(&circle, &curves));
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
// Another circle, a square and an empty loop do not.
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
// A loop only partly on them (a letter across the sheet's edge) is not on them.
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
assert!(!loop_lies_on(&notch, &curves));
}
#[test]
fn a_sheet_knows_the_letters_it_carries() {
// A recorded text region with a counter: its outline and its counter are letters, the same
// circle elsewhere, a loop near it that is another size and a loop far away are not.
let mut s = plate(100.0, 60.0);
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
let letters = s.letters();
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
assert!(letters.is_letter(&counter.reversed()));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
// A sheet that carries none has no letters.
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
}
#[test]
fn edge_flange_matches_fusion() {
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.