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

+1
View File
@@ -844,6 +844,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
spec.id,
"sheet.flange"
| "sheet.hem"
| "sheet.perforate"
| "sheet.fold"
| "sheet.unfold"
| "sheet.refold"
+17
View File
@@ -95,6 +95,23 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
b[k] = json!(b["kernel"][k].as_i64().unwrap_or(0) + d);
}
b["sheet_metal"] = json!(true);
// A perforation field (#732) is not cut into the B-rep: take its open area out of the volume, the mass
// and the area (both faces minus the holes, plus the bores).
let open = sh.perforated_area();
if open > 0.0 {
let v = b["volume_mm3"].as_f64().unwrap_or(0.0);
let nv = v - open * sh.t;
if let Some(m) = b["mass_g"].as_f64().filter(|_| v > 0.0) {
b["mass_g"] = json!(m * nv / v);
}
b["volume_mm3"] = json!(nv);
let bores: f64 = sh
.perforations
.iter()
.map(|p| sh.perforation_holes(p).map_or(0.0, |h| h.len() as f64) * std::f64::consts::PI * p.diameter * sh.t)
.sum();
b["area_mm2"] = json!(b["area_mm2"].as_f64().unwrap_or(0.0) - 2.0 * open + bores);
}
}
}
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
+57 -1
View File
@@ -20,6 +20,9 @@ pub static COMMANDS: &[CommandSpec] = &[
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused",
),
CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("hem").params(
"diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)",
),
CommandSpec::new("sheet.hem", "Hem", hem)
.at("SHEET METAL", "CREATE")
.icon("hem")
@@ -186,6 +189,23 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
}
fn perforate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.perforate";
let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let pitch = req_expr(cmd, p, "pitch")?;
check_expr(s, &pitch, Kind::Length, cmd, "pitch")?;
let margin = expr(p, "margin");
if let Some(m) = &margin {
check_expr(s, m, Kind::Length, cmd, "margin")?;
}
let pattern = str_(p, "pattern").map(str::to_string);
if pattern.as_deref().is_some_and(|t| t != "staggered" && t != "square") {
return Err(bad(cmd, "`pattern` is \"staggered\" (60°, default) or \"square\""));
}
add_feature(s, p, FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body: str_(p, "body").map(str::to_string) })
}
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.hem";
let edges = points(p, "edges", cmd)?;
@@ -442,7 +462,24 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
.iter()
.map(|b| json!({"start": [b.base.x, b.base.y, b.base.z], "axis": [b.axis.x, b.axis.y, b.axis.z], "diameter": b.radius * 2.0, "length": b.height.abs()}))
.collect();
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat});
let mut perforations = Vec::new();
for p in &sh.perforations {
let n = sh.perforation_holes(p)?.len();
perforations.push(json!({"feature": p.feature, "diameter": p.diameter, "pitch": p.pitch, "margin": p.margin,
"pattern": if p.square { "square" } else { "staggered" }, "count": n,
"open_area_mm2": n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2)}));
}
let bend_zones: Vec<Value> = sh
.bend_zones()
.iter()
.map(|z| {
let pts = z.outer.polyline(1e-3);
let f = |g: fn(&Vec2) -> f64, m: fn(f64, f64) -> f64, s: f64| pts.iter().map(g).fold(s, m);
json!([f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)])
})
.collect();
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat,
"perforations": perforations, "bend_zones": bend_zones});
if !bores.is_empty() {
out["bores"] = Value::Array(bores);
}
@@ -472,6 +509,17 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
for p in &pockets {
layers.insert(pocket_layer(p));
}
let perforation: Vec<(Vec2, f64)> = sh
.perforations
.iter()
.map(|p| sh.perforation_holes(p).map(|h| h.into_iter().map(|c| (c, p.diameter / 2.0)).collect::<Vec<_>>()))
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
if !perforation.is_empty() {
layers.insert("PERFORATION".to_string());
}
let mut out = String::new();
let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n"));
w(0, "SECTION");
@@ -559,6 +607,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
for b in sh.bends() {
line(&mut w, if b.up { "BEND_UP" } else { "BEND_DOWN" }, b.start, b.end);
}
for (c, r) in &perforation {
w(0, "CIRCLE");
w(8, "PERFORATION");
w(10, &c.x.to_string());
w(20, &c.y.to_string());
w(30, "0");
w(40, &r.to_string());
}
w(0, "ENDSEC");
w(0, "EOF");
Ok(out)
+150
View File
@@ -3823,3 +3823,153 @@ fn perforated_sheet_takes_a_thousand_round_holes() {
let expected = 1000.0 * std::f64::consts::PI * 0.5 * 0.5 * 0.8;
assert!((removed - expected).abs() / expected < 0.05, "removed {removed:.1} mm³, 1000 holes are {expected:.1} mm³");
}
// ---- #732 sheet.perforate: a perforation field stored on the sheet, not cut into the B-rep ----------------------
/// Hole centres of a staggered (60 deg) perforation over a w x h rectangle at z 0, as `sheet.perforate` places them:
/// rows along x every pitch * sqrt(3) / 2, odd rows shifted half a pitch, starting at margin + radius from the
/// lower-left corner; a hole is kept when it stays margin away from the outline.
fn staggered_count(w: f64, h: f64, d: f64, pitch: f64, margin: f64) -> usize {
let (r, dy) = (d / 2.0, pitch * 3f64.sqrt() / 2.0);
let (lo, hix, hiy) = (margin + r, w - margin - r, h - margin - r);
let mut n = 0;
let mut row = 0;
while lo + row as f64 * dy <= hiy + 1e-9 {
let mut x = lo + if row % 2 == 1 { pitch / 2.0 } else { 0.0 };
while x <= hix + 1e-9 {
n += 1;
x += pitch;
}
row += 1;
}
n
}
fn perforation_circles(dxf: &str) -> Vec<(f64, f64, f64)> {
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < lines.len() {
if lines[i] == "0" && lines[i + 1] == "CIRCLE" {
let (mut layer, mut x, mut y, mut r) = ("", 0.0, 0.0, 0.0);
let mut j = i + 2;
while j + 1 < lines.len() && lines[j] != "0" {
match lines[j] {
"8" => layer = lines[j + 1],
"10" => x = lines[j + 1].parse().unwrap_or(0.0),
"20" => y = lines[j + 1].parse().unwrap_or(0.0),
"40" => r = lines[j + 1].parse().unwrap_or(0.0),
_ => {}
}
j += 2;
}
if layer == "PERFORATION" {
out.push((x, y, r));
}
i = j;
} else {
i += 1;
}
}
out
}
/// #732: Rv 1.0-1.5 over a 100 x 60 x 0.8 sheet: every hole is in the flat pattern and in the DXF (layer
/// PERFORATION), placed on the documented grid, and the B-rep is not cut (the field is stored like a cosmetic thread).
#[test]
fn perforation_field_exports_every_hole_to_the_flat_pattern_and_dxf() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
let faces = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["faces"].clone();
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let expected = staggered_count(100.0, 60.0, 1.0, 1.5, 1.0);
let f = flat(&mut s);
assert_eq!(f["perforations"][0]["count"].as_u64(), Some(expected as u64), "{}", f["perforations"]);
assert_eq!(f["perforations"][0]["pattern"], "staggered");
let open = f["perforations"][0]["open_area_mm2"].as_f64().unwrap();
assert!(rel(open, expected as f64 * PI * 0.25) < 1e-9);
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let holes = perforation_circles(&dxf);
assert_eq!(holes.len(), expected);
assert!(holes.iter().all(|&(x, y, r)| (r - 0.5).abs() < 1e-9 && (1.5..=98.5).contains(&x) && (1.5..=58.5).contains(&y)));
assert!((holes[0].0 - 1.5).abs() < 1e-9 && (holes[0].1 - 1.5).abs() < 1e-9, "first hole at margin + radius: {:?}", holes[0]);
assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["faces"], faces, "the B-rep is not cut");
}
/// #732: the measured volume and mass drop by the open area times the thickness.
#[test]
fn perforation_reduces_volume_and_mass_by_the_open_area() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
let before = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let after = run(&mut s, "inspect.measure", json!({}));
let n = staggered_count(100.0, 60.0, 1.0, 1.5, 1.0) as f64;
let removed = before["total"]["volume_mm3"].as_f64().unwrap() - after["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(removed, n * PI * 0.25 * 0.8) < 1e-9, "removed {removed}");
let (m0, m1) = (before["bodies"][0]["mass_g"].as_f64().unwrap(), after["bodies"][0]["mass_g"].as_f64().unwrap());
let ratio = after["total"]["volume_mm3"].as_f64().unwrap() / before["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(m1 / m0, ratio) < 1e-9);
}
/// #732: holes stay out of bend zones, also when the fold comes after the perforation; the field follows the sheet
/// through unfold / refold and through save and reopen.
#[test]
fn perforation_skips_bend_zones_and_survives_fold_unfold_and_reopen() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let flat_count = flat(&mut s)["perforations"][0]["count"].as_u64().unwrap();
run(&mut s, "sheet.fold", json!({"points": [[80, 0, 0.8], [80, 60, 0.8]], "fixed": [40, 30, 0.4]}));
let folded = flat(&mut s);
let count = folded["perforations"][0]["count"].as_u64().unwrap();
assert!(count < flat_count, "holes in the new bend zone are dropped: {count} of {flat_count}");
let zone = &folded["bend_zones"][0]; // [min x, max x, min y, max y] of the bend zone, flat coordinates
let (z0, z1) = (zone[0].as_f64().unwrap_or(f64::NAN), zone[1].as_f64().unwrap_or(f64::NAN));
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert!(
perforation_circles(&dxf).iter().all(|&(x, _, r)| x + r + 1.0 <= z0 + 1e-9 || x - r - 1.0 >= z1 - 1e-9),
"no hole within margin of the bend zone x {z0}..{z1}"
);
let volume_folded = volume(&mut s);
run(&mut s, "sheet.unfold", json!({}));
assert_eq!(flat(&mut s)["perforations"][0]["count"].as_u64(), Some(count));
run(&mut s, "sheet.refold", json!({}));
assert!(rel(volume(&mut s), volume_folded) < 1e-9);
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(flat(&mut reopened)["perforations"][0]["count"].as_u64(), Some(count));
assert!(rel(volume(&mut reopened), volume_folded) < 1e-9);
}
/// #732: a real mesh panel - 400 x 470 with Rv 1.0-1.5 is about 94 000 holes - is quick to perforate, measure and
/// export (the holes never enter the flat-geometry budget).
#[test]
fn perforating_a_mesh_panel_with_ninety_thousand_holes_is_fast() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [400, 0], [400, 470], [0, 470]]));
let t = std::time::Instant::now();
run(&mut s, "sheet.perforate", json!({"diameter": "1", "pitch": "1.5", "margin": "1"}));
let count = flat(&mut s)["perforations"][0]["count"].as_u64().unwrap();
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().len();
volume(&mut s);
assert!(count > 90_000, "{count}");
assert!(dxf > 90_000 * 30, "every hole is in the DXF");
assert!(t.elapsed().as_secs_f64() < 20.0, "took {:?}", t.elapsed());
}
/// #732: a square pattern, and values that cannot be punched are refused.
#[test]
fn perforate_supports_a_square_pattern_and_refuses_impossible_values() {
let mut s = Session::default();
outline_blank(&mut s, json!([[0, 0], [30, 0], [30, 20], [0, 20]]));
run(&mut s, "sheet.perforate", json!({"diameter": "2", "pitch": "4", "margin": "0", "pattern": "square"}));
assert_eq!(flat(&mut s)["perforations"][0]["count"].as_u64(), Some(8 * 5));
for bad in [
json!({"diameter": "0", "pitch": "2"}),
json!({"diameter": "2", "pitch": "2"}),
json!({"diameter": "1", "pitch": "2", "pattern": "hexagon"}),
json!({"diameter": "1", "pitch": "2", "margin": "-1"}),
] {
assert!(s.execute("sheet.perforate", &bad).is_err(), "{bad} must be refused");
}
}
+1 -1
View File
@@ -1475,7 +1475,7 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = json!("solidcraft/18");
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap());
assert_eq!(reopened.doc.format, "solidcraft/20");
assert_eq!(reopened.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
let sk = sketch(&reopened);
assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8);