Sheet metal: sheet.perforate, a perforation field exported to the flat pattern and DXF (#732) (#1041)

Adds sheet.perforate: round holes on a staggered or square grid over the flat sheet, kept clear of edges, cut-outs and bend zones. The holes go to the flat pattern, the DXF and the measured volume rather than the B-rep, so a panel can carry many thousands of them.
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-14 12:43:31 -04:00
1 parent d399effbf7
commit 54a8c807bf
23 files changed
+413 -6

No files matched your search

+4 -1
View File
@@ -300,7 +300,10 @@ impl FeatureKind {
dir(direction);
dir(hook);
}
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
FeatureKind::SheetBase { .. }
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetPerforate { .. } => {}
FeatureKind::Coil { base, axis, .. } => {
map_point_expr(m, base);
dir(axis);
+17
View File
@@ -808,6 +808,20 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A perforation field (#732): round holes of `diameter` on a staggered (60°) or square grid of `pitch` over the
/// whole flat sheet, kept `margin` (default: the diameter) clear of its edges, cut-outs and bend zones. Stored like
/// a cosmetic thread: the holes go to the flat pattern, the DXF and the measured volume, not into the B-rep, so a
/// panel can carry tens of thousands of them.
SheetPerforate {
diameter: String,
pitch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
margin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Fold a sheet along a line on its base face: a sketch line (followed when the sketch
/// changes), else the points `a`, `b`.
SheetFold {
@@ -1354,6 +1368,7 @@ impl FeatureKind {
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
@@ -1407,6 +1422,7 @@ impl FeatureKind {
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
FeatureKind::SheetHem { .. } => "HemFeature",
FeatureKind::SheetPerforate { .. } => "PerforateFeature",
FeatureKind::SheetFold { .. } => "FoldFeature",
FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature",
FeatureKind::SheetUnfold { .. } => "RefoldFeature",
@@ -1480,6 +1496,7 @@ impl FeatureKind {
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
FeatureKind::SheetHem { .. } => "Hem",
FeatureKind::SheetPerforate { .. } => "Perforate",
FeatureKind::SheetFold { .. } => "Fold",
FeatureKind::SheetUnfold { refold: false, .. } => "Unfold",
FeatureKind::SheetUnfold { .. } => "Refold",
+1
View File
@@ -3908,6 +3908,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st, warning),
+26
View File
@@ -485,6 +485,32 @@ pub(super) fn sheet_eval(
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body } => {
// Without a body name the field goes on the newest sheet, as the command's own body lookup does.
let i = match body {
Some(_) => sheet_for(st, body, None)?,
None => st.sheets.len().checked_sub(1).ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))?,
};
let d = val(vals, diameter, Kind::Length)?;
let pitch = val(vals, pitch, Kind::Length)?;
let margin = match margin {
Some(e) => val(vals, e, Kind::Length)?,
None => d,
};
let square = match pattern.as_deref() {
None | Some("staggered") => false,
Some("square") => true,
Some(o) => return Err(DocError::Invalid(format!("perforation pattern {o:?}: use \"staggered\" or \"square\""))),
};
if !(d > 0.0 && pitch > d && margin >= 0.0) {
return Err(DocError::Invalid(format!(
"perforation needs diameter > 0, pitch > diameter and margin >= 0 (diameter {d}, pitch {pitch}, margin {margin})"
)));
}
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
Ok(())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
let i = sheet_for(st, body, edges.first().copied())?;
let l = val(vals, length, Kind::Length)?;
+1
View File
@@ -163,6 +163,7 @@ macro_rules! feature_walk {
| FeatureKind::DeleteFace { body, .. }
| FeatureKind::SheetFlange { body, .. }
| FeatureKind::SheetHem { body, .. }
| FeatureKind::SheetPerforate { body, .. }
| FeatureKind::SheetUnfold { body, .. }
| FeatureKind::Boss { body, .. }
| FeatureKind::Lip { body, .. }
@@ -112,6 +112,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}),
),
("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})),
(
"sheet_perforate",
json!({"type": "sheet_perforate", "diameter": "perforate.diameter", "pitch": "perforate.pitch", "margin": "perforate.margin"}),
),
(
"sheet_fold",
json!({"type": "sheet_fold", "a": o, "b": [1, 0, 0], "angle": "fold.angle", "radius": "fold.radius", "position": "centerline"}),
@@ -165,8 +169,8 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
let extra = match name {
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_unfold" | "boss" | "lip" | "snap_fit" | "rest"
| "surface_offset" => {
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
| "snap_fit" | "rest" | "surface_offset" => {
json!({"body": "B"})
}
"combine" => json!({"target": "B", "tools": ["B"]}),
@@ -244,6 +244,12 @@ sheet_hem
Length = hem.length (Length)
Gap = hem.gap (Length)
expressions: hem.length, hem.gap
sheet_perforate
inputs:
Diameter = perforate.diameter (Length)
Pitch = perforate.pitch (Length)
Margin = perforate.margin (Length)
expressions: perforate.diameter, perforate.pitch, perforate.margin
sheet_fold
inputs:
Angle = fold.angle (Angle)
@@ -120,6 +120,8 @@ sheet_flange
remap_bodies: body
sheet_hem
remap_bodies: body
sheet_perforate
remap_bodies: body
sheet_fold
sketches: [7]
remap_refs: sketch
+4
View File
@@ -143,6 +143,10 @@
//! no transformation, and an extrude without `thin` saves as before; the version stamp makes
//! 0.10.0 (which reads up to 19) ask for an update instead of ignoring the wall and filling
//! the sketch's profiles.
//! A `sheet_perforate` feature (Perforate: `diameter`, `pitch`, optional `margin`,
//! `pattern` and `body`): a perforation field stored on a sheet, for the flat pattern, the DXF
//! and the measure. It is also first saved in 20; 0.10.0 asks for an update instead of
//! meeting a feature it can't read.
use serde_json::Value;
+7
View File
@@ -323,6 +323,13 @@ macro_rules! feature_walk {
f("Gap", g, L, I);
}
}
FeatureKind::SheetPerforate { diameter, pitch, margin, .. } => {
f("Diameter", diameter, L, I);
f("Pitch", pitch, L, I);
if let Some(m) = margin {
f("Margin", m, L, I);
}
}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
f("Diameter", diameter, L, I);
f("Pitch", pitch, L, I);
+106
View File
@@ -482,6 +482,9 @@ pub struct SheetBody {
/// pattern both carry it (#560). Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub bores: Vec<SheetBore>,
/// 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>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -509,6 +512,16 @@ pub struct SheetBore {
pub height: f64,
}
/// A perforation field from a `sheet.perforate` feature (#732).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SheetPerforation {
pub feature: u64,
pub diameter: f64,
pub pitch: f64,
pub margin: f64,
pub square: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SheetPocket {
pub region: Region2,
@@ -1695,6 +1708,97 @@ impl SheetBody {
out
}
/// The flat bend zones, one per bend.
pub fn bend_zones(&self) -> Vec<Region2> {
self.flanges.iter().map(|f| self.zone(f)).collect()
}
/// Hole centres of a perforation field (#732), flat coordinates: a staggered (60°, odd rows shifted half a pitch)
/// or square grid from the flat bounding box's lower-left corner plus margin + radius; a hole is kept where it is
/// at least margin clear of the material's edges and cut-outs and of every bend zone.
pub fn perforation_holes(&self, p: &SheetPerforation) -> Result<Vec<Vec2>> {
let regions = self.flat_regions()?;
let zones = self.bend_zones();
let clear = p.diameter / 2.0 + p.margin;
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
let mut add = |l: &Loop2| {
let pts = l.polyline(1e-3);
for k in 0..pts.len() {
if let (Some(&a), Some(&b)) = (pts.get(k), pts.get((k + 1) % pts.len())) {
segs.push((a, b));
}
}
};
for r in &regions {
add(&r.outer);
r.holes.iter().for_each(&mut add);
}
zones.iter().for_each(|z| add(&z.outer));
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for (a, _) in &segs {
lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y));
hi = Vec2::new(hi.x.max(a.x), hi.y.max(a.y));
}
if !(lo.x <= hi.x) || !(p.pitch > 0.0) {
return Ok(Vec::new());
}
// Segments bucketed in a grid, so each hole only checks the edges near it.
let cell = (2.0 * clear).max(p.pitch).max(1.0);
let (nx, ny) = (((hi.x - lo.x) / cell) as usize + 1, ((hi.y - lo.y) / cell) as usize + 1);
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); nx.saturating_mul(ny)];
let idx = |x: f64, y: f64| -> (usize, usize) {
(((x - lo.x) / cell).floor().clamp(0.0, (nx - 1) as f64) as usize, ((y - lo.y) / cell).floor().clamp(0.0, (ny - 1) as f64) as usize)
};
for (i, (a, b)) in segs.iter().enumerate() {
let (i0, j0) = idx(a.x.min(b.x) - clear, a.y.min(b.y) - clear);
let (i1, j1) = idx(a.x.max(b.x) + clear, a.y.max(b.y) + clear);
for j in j0..=j1 {
for ii in i0..=i1 {
if let Some(c) = grid.get_mut(j * nx + ii) {
c.push(i);
}
}
}
}
let dist = |c: Vec2, (a, b): (Vec2, Vec2)| -> f64 {
let (d, l) = (b - a, (b - a).dot(b - a));
let t = if l > 0.0 { ((c - a).dot(d) / l).clamp(0.0, 1.0) } else { 0.0 };
c.dist(a + d * t)
};
let dy = if p.square { p.pitch } else { p.pitch * 3f64.sqrt() / 2.0 };
let mut out = Vec::new();
let mut row = 0usize;
loop {
let y = lo.y + clear + row as f64 * dy;
if y > hi.y - clear + 1e-9 {
break;
}
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
while x <= hi.x - clear + 1e-9 {
let c = Vec2::new(x, y);
let (i, j) = idx(x, y);
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
if regions.iter().any(|r| r.contains(c))
&& !zones.iter().any(|z| z.contains(c))
&& near.iter().all(|&k| segs.get(k).is_none_or(|&s| dist(c, s) >= clear - 1e-9))
{
out.push(c);
}
x += p.pitch;
}
row += 1;
}
Ok(out)
}
/// Open area of all perforation fields (mm²): what they take out of the sheet's faces.
pub fn perforated_area(&self) -> f64 {
self.perforations
.iter()
.map(|p| self.perforation_holes(p).map_or(0.0, |h| h.len() as f64) * std::f64::consts::PI * (p.diameter / 2.0).powi(2))
.sum()
}
/// Topology counted as Fusion does: a round hole is one face bounded by two closed circular
/// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to
/// the sewn (faces, edges, vertices).
@@ -3844,6 +3948,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
holes,
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -3900,6 +4005,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
holes: Vec::new(),
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,