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
@@ -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)],
|
||||
|
||||
Reference in new issue
Block a user