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:
1 parent
d399effbf7
commit
54a8c807bf
23 files changed
+413
-6
No files matched your search
@@ -542,6 +542,7 @@
|
||||
"cmd.sheet_fold": "Falten",
|
||||
"cmd.sheet_hem": "Umkantung",
|
||||
"cmd.sheet_manage_rules": "Blechregeln",
|
||||
"cmd.sheet_perforate": "Perforieren",
|
||||
"cmd.sheet_refold": "Wieder falten",
|
||||
"cmd.sheet_rules": "Blechregeln auflisten",
|
||||
"cmd.sheet_unfold": "Entfalten",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"cmd.sheet_fold": "Fold",
|
||||
"cmd.sheet_hem": "Hem",
|
||||
"cmd.sheet_manage_rules": "Sheet Metal Rules",
|
||||
"cmd.sheet_perforate": "Perforate",
|
||||
"cmd.sheet_refold": "Refold",
|
||||
"cmd.sheet_rules": "List Sheet Metal Rules",
|
||||
"cmd.sheet_unfold": "Unfold",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"cmd.sheet_fold": "折り曲げ",
|
||||
"cmd.sheet_hem": "ヘミング",
|
||||
"cmd.sheet_manage_rules": "板金ルール",
|
||||
"cmd.sheet_perforate": "パンチング",
|
||||
"cmd.sheet_refold": "折り戻し",
|
||||
"cmd.sheet_rules": "板金ルールを一覧表示",
|
||||
"cmd.sheet_unfold": "展開",
|
||||
|
||||
@@ -542,6 +542,7 @@
|
||||
"cmd.sheet_fold": "접기",
|
||||
"cmd.sheet_hem": "헴",
|
||||
"cmd.sheet_manage_rules": "판금 규칙",
|
||||
"cmd.sheet_perforate": "타공",
|
||||
"cmd.sheet_refold": "다시 접기",
|
||||
"cmd.sheet_rules": "판금 규칙 목록",
|
||||
"cmd.sheet_unfold": "펼치기",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"cmd.sheet_fold": "折叠",
|
||||
"cmd.sheet_hem": "卷边",
|
||||
"cmd.sheet_manage_rules": "钣金规则",
|
||||
"cmd.sheet_perforate": "冲孔",
|
||||
"cmd.sheet_refold": "重新折弯",
|
||||
"cmd.sheet_rules": "列出钣金规则",
|
||||
"cmd.sheet_unfold": "展开",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ®ions {
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -67,6 +67,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"sheet.convert" => &[("face", Pick), ("bends", Picks), ("body", Body)],
|
||||
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
||||
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
||||
"sheet.perforate" => &[("body", Body)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
|
||||
"canvas.decal" => &[("face", Here)],
|
||||
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
|
||||
|
||||
@@ -73,7 +73,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
|
||||
The default `cache: false` omits the portable thumbnail too.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 20, including thin extrudes, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
Current designs use format 20, including thin extrudes, sheet metal perforation fields, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
|
||||
links, persistent
|
||||
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||
|
||||
Reference in new issue
Block a user