From 54a8c807bf205a12b1c15d896e8325c2adfd2ae6 Mon Sep 17 00:00:00 2001 From: Jonas Wrede Date: Wed, 14 Oct 2026 18:43:31 +0200 Subject: [PATCH] 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. --- assets/locales/de.json | 1 + assets/locales/en.json | 1 + assets/locales/ja.json | 1 + assets/locales/ko.json | 1 + assets/locales/zh.json | 1 + crates/doc/src/assembly.rs | 5 +- crates/doc/src/document.rs | 17 ++ crates/doc/src/eval.rs | 1 + crates/doc/src/eval_more.rs | 26 +++ crates/doc/src/feature_refs.rs | 1 + crates/doc/src/feature_walk_tests/fixtures.rs | 8 +- .../src/feature_walk_tests/golden_exprs.txt | 6 + .../src/feature_walk_tests/golden_refs.txt | 2 + crates/doc/src/format.rs | 4 + crates/doc/src/params.rs | 7 + crates/doc/src/sheet.rs | 106 +++++++++++++ crates/engine/src/cmd/edit.rs | 1 + crates/engine/src/cmd/inspect.rs | 17 ++ crates/engine/src/cmd/sheet.rs | 58 ++++++- crates/engine/src/cmd/sheet_tests.rs | 150 ++++++++++++++++++ crates/engine/src/cmd/sketch_modify_tests.rs | 2 +- crates/engine/src/frames.rs | 1 + docs/book/src/guide/files.md | 2 +- 23 files changed, 413 insertions(+), 6 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index 383b5f09..38d989e1 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index 05a2638b..2474b6e6 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 6a866338..789688e8 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "展開", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 00ceef15..4b31783d 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "펼치기", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index c18e737c..422d1363 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "展开", diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 97c362b1..da030dae 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 37f71456..4841021c 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -808,6 +808,20 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, }, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pattern: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, /// 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", diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index c578b3e5..7738b9ca 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3908,6 +3908,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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), diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index bdd80d5d..58d7d1f9 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -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)?; diff --git a/crates/doc/src/feature_refs.rs b/crates/doc/src/feature_refs.rs index e22795ec..c1e4a216 100644 --- a/crates/doc/src/feature_refs.rs +++ b/crates/doc/src/feature_refs.rs @@ -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, .. } diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index b94e5fb5..b296d1f5 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -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"]}), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index 5525396f..186d32ea 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -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) diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index 549d4e3f..5bb056dc 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -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 diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 3da8eb59..91b155b9 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 43c8bfaf..3bb08ffc 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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); diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 7dad5247..6c716803 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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, + /// 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, /// 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 { + 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> { + 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![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, diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b9f3c2e8..cf7578c8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -844,6 +844,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { spec.id, "sheet.flange" | "sheet.hem" + | "sheet.perforate" | "sheet.fold" | "sheet.unfold" | "sheet.refold" diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index c68d1043..7b08972c 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -95,6 +95,23 @@ fn measure(s: &mut Session, p: &Value) -> Result { 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(); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 2d98d423..48553171 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -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 { } } +fn perforate(s: &mut Session, p: &Value) -> Result { + 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 { let cmd = "sheet.hem"; let edges = points(p, "edges", cmd)?; @@ -442,7 +462,24 @@ fn flat_json(sh: &SheetBody) -> Result { .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 = 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 { 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::>())) + .collect::, _>>()? + .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 { 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) diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 1724a0d5..fe9c31a5 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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"); + } +} diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 60ea4f0c..31f73668 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -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); diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 8351b2e4..e8e34260 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -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)], diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 0bdf6c6e..152e53dd 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -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