feat: Convert to Sheet Metal takes a solid or a shelled body by its bend edges (#725)
Convert to Sheet Metal now takes a solid or a shelled body by its bend edges (`bends`): the faces reached from the fixed face across those edges become the sheet, keeping the part's envelope, with the flat pattern by the rule's K-factor and radius. Other edges between sheet faces are cut open. The result works with edge flanges, cuts, Unfold/Refold, the flat pattern and DXF. A conversion without `bends` is the plate conversion as before. The optional `bends` field is noted under format 17. Fixes #647. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a513304917
commit
a1cb8d4d36
16 files changed
+1044
-20
No files matched your search
@@ -342,6 +342,12 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
|
||||
}
|
||||
}
|
||||
// New bend edges for a conversion: the names of the body's edges there now.
|
||||
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
|
||||
&& (a != b || body != was)
|
||||
{
|
||||
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, a);
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
|
||||
@@ -394,6 +394,10 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
|
||||
}
|
||||
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
|
||||
// A conversion's bend edges are edges of the body it converts.
|
||||
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
|
||||
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, bends))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let face_names = {
|
||||
|
||||
@@ -34,7 +34,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("convert_sheet")
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
.params(
|
||||
"body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?; \
|
||||
bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \
|
||||
across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \
|
||||
edges between two of those faces that are no bends are cut open with the rule's gap)",
|
||||
),
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
|
||||
@@ -272,8 +277,17 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
|
||||
// With bend edges, a solid or shelled body; without, a plate.
|
||||
let bends = match p.get("bends") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(_) => points(p, "bends", cmd)?,
|
||||
};
|
||||
// Such a sheet's values are its rule's (a plate's thickness is the plate's own).
|
||||
if let Some(key) = RULE_ONLY.iter().find(|k| !bends.is_empty() && p.get(**k).is_some()) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sheet.convert parameter: the sheet metal rule sets it. Pass a rule's name as `rule`")));
|
||||
}
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule })
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule, bends })
|
||||
}
|
||||
|
||||
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -599,3 +613,7 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_convert_tests.rs"]
|
||||
mod convert_tests;
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Convert to Sheet Metal with bend edges: solid and shelled bodies.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn bbox(s: &mut Session) -> ([f64; 3], [f64; 3]) {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let b = &m["bodies"][0]["bbox"];
|
||||
let v = |k: &str| [0, 1, 2].map(|i| b[k][i].as_f64().unwrap_or(f64::NAN));
|
||||
(v("min"), v("max"))
|
||||
}
|
||||
|
||||
fn flat(s: &mut Session) -> Value {
|
||||
run(s, "sheet.flat_pattern", json!({}))
|
||||
}
|
||||
|
||||
fn flat_size(s: &mut Session) -> (f64, f64) {
|
||||
let f = flat(s);
|
||||
let v = |i: usize| f["flat_size_mm"][i].as_f64().unwrap_or(f64::NAN);
|
||||
(v(0), v(1))
|
||||
}
|
||||
|
||||
/// Either way round (the flat frame's x runs along the fixed face's longest edge).
|
||||
fn same_size(got: (f64, f64), want: (f64, f64), tol: f64) -> bool {
|
||||
((got.0 - want.0).abs() < tol && (got.1 - want.1).abs() < tol) || ((got.0 - want.1).abs() < tol && (got.1 - want.0).abs() < tol)
|
||||
}
|
||||
|
||||
/// The bend table's rows as (angle in whole degrees, direction, length).
|
||||
fn bend_rows(s: &mut Session) -> Vec<(i64, String, f64)> {
|
||||
let rows = flat(s)["bends"].as_array().cloned().unwrap_or_default();
|
||||
let row = |b: &Value| {
|
||||
(
|
||||
b["angle_deg"].as_f64().unwrap_or(0.0).round() as i64,
|
||||
b["direction"].as_str().unwrap_or("").to_string(),
|
||||
b["length"].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
};
|
||||
rows.iter().map(row).collect()
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, name: &str, t: f64, k: f64, r: f64, gap: f64) {
|
||||
let mm = |v: f64| format!("{v} mm");
|
||||
run(
|
||||
s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": name, "thickness": mm(t), "k_factor": format!("{k}"), "bend_radius": mm(r), "gap": mm(gap), "active": true}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bend deduction: twice the outside setback less the allowance.
|
||||
fn deduction(theta: f64, r: f64, t: f64, k: f64) -> f64 {
|
||||
2.0 * (r + t) * (theta / 2.0).tan() - theta * (r + k * t)
|
||||
}
|
||||
|
||||
const BOTTOM_EDGES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]];
|
||||
|
||||
/// A 100 x 60 x 30 box shelled to `t` with its top open.
|
||||
fn tray(s: &mut Session, t: f64) {
|
||||
run(s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": t}));
|
||||
}
|
||||
|
||||
/// The folded tray's volume: base, four 90° bends and four walls cut back at the corners.
|
||||
fn tray_volume(l: f64, w: f64, h: f64, t: f64, r: f64, gap: f64) -> f64 {
|
||||
let s = r + t;
|
||||
let cut = t + gap / SQRT_2;
|
||||
let base = (l - 2.0 * s) * (w - 2.0 * s);
|
||||
let bends = FRAC_PI_2 * (r + t / 2.0) * 2.0 * ((l - 2.0 * s) + (w - 2.0 * s));
|
||||
let walls = (h - s) * 2.0 * ((l - 2.0 * cut) + (w - 2.0 * cut));
|
||||
t * (base + bends + walls)
|
||||
}
|
||||
|
||||
/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line
|
||||
/// runs, its ends cut square), extruded `len` along z as the body `Section`. Returns a point on
|
||||
/// the edge at each corner of the polyline.
|
||||
fn section(s: &mut Session, outer: &[(f64, f64)], t: f64, len: f64) -> Vec<[f64; 3]> {
|
||||
let n = outer.len();
|
||||
let dir = |i: usize| {
|
||||
let (a, b) = (outer[i], outer[i + 1]);
|
||||
let l = (b.0 - a.0).hypot(b.1 - a.1);
|
||||
((b.0 - a.0) / l, (b.1 - a.1) / l)
|
||||
};
|
||||
let shift = |p: (f64, f64), d: (f64, f64)| (p.0 - d.1 * t, p.1 + d.0 * t);
|
||||
let mut inner = vec![shift(outer[0], dir(0))];
|
||||
for (i, &corner) in outer.iter().enumerate().take(n - 1).skip(1) {
|
||||
let (d0, d1) = (dir(i - 1), dir(i));
|
||||
let (p0, p1) = (shift(corner, d0), shift(corner, d1));
|
||||
let along = ((p1.0 - p0.0) * d1.1 - (p1.1 - p0.1) * d1.0) / (d0.0 * d1.1 - d0.1 * d1.0);
|
||||
inner.push((p0.0 + d0.0 * along, p0.1 + d0.1 * along));
|
||||
}
|
||||
inner.push(shift(outer[n - 1], dir(n - 2)));
|
||||
let pts: Vec<[f64; 2]> = outer.iter().chain(inner.iter().rev()).map(|p| [p.0, p.1]).collect();
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||||
run(s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"}));
|
||||
outer[1..n - 1].iter().map(|p| [p.0, p.1, len / 2.0]).collect()
|
||||
}
|
||||
|
||||
/// A base 200 wide with walls 40 long at 72° and returns 15 long at 108° more, 300 long.
|
||||
fn trapezoid_section(s: &mut Session, t: f64) -> Vec<[f64; 3]> {
|
||||
let (c, sn) = (72f64.to_radians().cos(), 72f64.to_radians().sin());
|
||||
let (x, y) = (100.0 + 40.0 * c, 40.0 * sn);
|
||||
section(s, &[(-x + 15.0, y), (-x, y), (-100.0, 0.0), (100.0, 0.0), (x, y), (x - 15.0, y)], t, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shelled_tray_keeps_its_walls_and_unfolds_by_the_k_factor() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
// The shell's own thickness counts, not the rule's.
|
||||
rule(&mut s, "Test", 3.0, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
assert!(out["warning"].is_null(), "{out}");
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// Four 90° bends up, each as long as the base is between its setbacks (R + T at either end).
|
||||
let mut rows = bend_rows(&mut s);
|
||||
rows.sort_by(|a, b| a.2.total_cmp(&b.2));
|
||||
let want = [52.0, 52.0, 92.0, 92.0];
|
||||
assert!(rows.len() == 4 && rows.iter().zip(want).all(|(row, l)| row.0 == 90 && row.1 == "up" && (row.2 - l).abs() < 1e-6), "{rows:?}");
|
||||
let allowance = flat(&mut s)["bends"][0]["allowance"].as_f64().unwrap_or(0.0);
|
||||
assert!((allowance - FRAC_PI_2 * (r + k * t)).abs() < 1e-9, "{allowance}");
|
||||
// The envelope stays: the sharp edges became bends inside it.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Flat: the outside lengths less two bend deductions each way.
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
let want = (100.0 + 60.0 - 2.0 * bd, 60.0 + 60.0 - 2.0 * bd);
|
||||
assert!(same_size(flat_size(&mut s), want, 1e-6), "{:?} vs {want:?}", flat_size(&mut s));
|
||||
let v = volume(&mut s);
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((v - expect).abs() < 2e-3 * expect, "{v} vs {expect}");
|
||||
// Unfold: flat, one sheet thick, the same outline; refold gives the tray back.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!((hi[2] - lo[2] - t).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
assert!(same_size((hi[0] - lo[0], hi[1] - lo[1]), want, 1e-6), "{lo:?} {hi:?}");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
// It exports like a sheet made from flanges: an outline and four bend lines up.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
assert_eq!(dxf["bends"], 4);
|
||||
let lines: Vec<&str> = dxf["dxf"].as_str().unwrap_or_default().lines().collect();
|
||||
assert_eq!(lines.windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(), 4, "four bend lines");
|
||||
assert_eq!(run(&mut s, "sheet.export_bend_table", json!({"format": "json"}))["bends"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bends_picked_inside_and_a_fixed_face_inside_give_the_same_sheet() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let inner = [[50.0, 2.0, 2.0], [98.0, 30.0, 2.0], [50.0, 58.0, 2.0], [2.0, 30.0, 2.0]];
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let (v, f) = (volume(&mut s), flat_size(&mut s));
|
||||
// The inner edges (2 mm in and up) are the same bends.
|
||||
let mut a = Session::default();
|
||||
rule(&mut a, "Test", t, k, r, gap);
|
||||
tray(&mut a, t);
|
||||
run(&mut a, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": inner}));
|
||||
assert!((volume(&mut a) - v).abs() < 1e-6 * v && same_size(flat_size(&mut a), f, 1e-9));
|
||||
// From the floor inside, the sheet's other skin is the fixed one: the bends turn down from it
|
||||
// and the flat is the inside lengths less the inside setbacks, L' + 2H' - 4R + 2BA, which is
|
||||
// the same blank.
|
||||
let mut b = Session::default();
|
||||
rule(&mut b, "Test", t, k, r, gap);
|
||||
tray(&mut b, t);
|
||||
run(&mut b, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 2], "bends": BOTTOM_EDGES}));
|
||||
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b));
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let inside = (96.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba, 56.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba);
|
||||
assert!(same_size(flat_size(&mut b), inside, 1e-6) && same_size(inside, f, 1e-6), "{:?} {inside:?} {f:?}", flat_size(&mut b));
|
||||
assert!((volume(&mut b) - v).abs() < 1e-6 * v, "{} vs {v}", volume(&mut b));
|
||||
let (lo, hi) = bbox(&mut b);
|
||||
assert!(lo.iter().all(|x| x.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solid_block_takes_its_rules_thickness_inward() {
|
||||
let (k, r, gap) = (0.44, 2.0, 0.5);
|
||||
for (name, t) in [(None, 2.0), (Some("Thick"), 3.0)] {
|
||||
// The active rule, or the one the conversion names.
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Thick", 3.0, k, r, gap);
|
||||
rule(&mut s, "Test", 2.0, k, r, gap);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"}));
|
||||
let mut p = json!({"body": "Block", "face": [50, 30, 0], "bends": BOTTOM_EDGES});
|
||||
if let Some(n) = name {
|
||||
p["rule"] = json!(n);
|
||||
}
|
||||
run(&mut s, "sheet.convert", p);
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// The block's faces are the sheet's outside; its top is left out.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd, 120.0 - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trapezoid_section_unfolds_through_72_and_108_degree_bends() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
let bends = trapezoid_section(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [0, 0, 150], "bends": bends}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.1 == "up" && (row.2 - 300.0).abs() < 1e-9), "{rows:?}");
|
||||
assert_eq!((rows.iter().filter(|row| row.0 == 72).count(), rows.iter().filter(|row| row.0 == 108).count()), (2, 2), "{rows:?}");
|
||||
let (a72, a108) = (72f64.to_radians(), 108f64.to_radians());
|
||||
let across = 200.0 + 2.0 * 40.0 + 2.0 * 15.0 - 2.0 * deduction(a72, r, t, k) - 2.0 * deduction(a108, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (across, 300.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// The section keeps its outside faces: the returns' tops stay 40 sin 72° up and the length
|
||||
// stays; the widest point is on the round of the 108° bend (outer radius R + T), whose
|
||||
// centre lies (R + T) / sin 36° from the sharp corner along the corner's bisector.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let (c72, s72) = (a72.cos(), a72.sin());
|
||||
let rho = r + t;
|
||||
let bisector_x = (-c72 - 1.0) / (-c72 - 1.0).hypot(-s72);
|
||||
let wide = 2.0 * (100.0 + 40.0 * c72 + bisector_x * rho / 36f64.to_radians().sin() + rho);
|
||||
// (The box is measured on the tessellation: the round comes out a chord's sag inside.)
|
||||
assert!((hi[0] - lo[0] - wide).abs() < 2e-3 && (hi[1] - 40.0 * s72).abs() < 1e-6 && (hi[2] - lo[2] - 300.0).abs() < 1e-6, "{lo:?} {hi:?} {wide}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let flat_box = [(hi[0] - lo[0], hi[1] - lo[1]), (hi[0] - lo[0], hi[2] - lo[2])];
|
||||
assert!(flat_box.iter().any(|b| same_size(*b, (across, 300.0), 1e-6)), "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_z_section_bends_up_at_one_edge_and_down_at_the_other() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
// Flange 60, web 40, flange 30 on one skin: its first corner is the outside of its bend,
|
||||
// its second the inside.
|
||||
let bends = section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], t, 100.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": bends}));
|
||||
let mut directions: Vec<String> = bend_rows(&mut s).into_iter().map(|row| row.1).collect();
|
||||
directions.sort();
|
||||
assert_eq!(directions, ["down", "up"]);
|
||||
// Outside setbacks R + T at the first bend, inside setbacks R at the second, and a 90°
|
||||
// allowance for each: 60 + 40 + 30 - 2 (R + T) - 2 R + π (R + K T) = 128.2832.
|
||||
let across = 130.0 - 2.0 * (r + t) - 2.0 * r + PI * (r + k * t);
|
||||
assert!((across - 128.283185).abs() < 1e-6);
|
||||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 90.0).abs() < 1e-6 && (hi[1] - 42.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Three flat strips and two quarter rounds on the neutral radius R + T/2.
|
||||
let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t) - r) + (30.0 - r) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tapered_tray_converts_with_72_degree_walls_and_slanted_corners() {
|
||||
let (t, k, r, gap) = (2.0, 0.5, 1.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Floor"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 120]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Walls leaning out 18°: 72° bends from the floor.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(hi[0] - lo[0] > 200.0 + 1.0, "the taper widens the top: {lo:?} {hi:?}");
|
||||
run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": t}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [100, 60, 0], "bends": [[100, 0, 0], [200, 60, 0], [100, 120, 0], [0, 60, 0]]}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 72 && row.1 == "up"), "{rows:?}");
|
||||
let slant = 40.0 / 72f64.to_radians().sin();
|
||||
let bd = deduction(72f64.to_radians(), r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (200.0 + 2.0 * slant - 2.0 * bd, 120.0 + 2.0 * slant - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
// The outside stays; the rim is cut square to the sheet, as a blank is, so the inside edge
|
||||
// of a wall leaning out 18° stands T sin 18° above the solid's level rim.
|
||||
let (lo2, hi2) = bbox(&mut s);
|
||||
assert!((0..2).all(|i| (lo2[i] - lo[i]).abs() < 1e-6 && (hi2[i] - hi[i]).abs() < 1e-6) && lo2[2].abs() < 1e-6, "{lo2:?} {hi2:?}");
|
||||
assert!((hi2[2] - 40.0 - t * 18f64.to_radians().sin()).abs() < 1e-6, "{hi2:?}");
|
||||
let v = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sheet_follows_the_solid_through_parameter_changes_undo_and_reopening() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
run(&mut s, "parameters.add", json!({"name": "L", "expression": "100 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "T", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "L", "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": "T"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let bd = |t: f64| deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
// Longer: the right-hand bend edge moved from x = 100 to x = 140 and is found by its name.
|
||||
run(&mut s, "parameters.change", json!({"name": "L", "expression": "140 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()), "{:?}", s.model.results.iter().map(|x| (&x.name, &x.error)).collect::<Vec<_>>());
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let (_, hi) = bbox(&mut s);
|
||||
assert!((hi[0] - 140.0).abs() < 1e-6, "{hi:?}");
|
||||
// Thicker walls: the sheet takes the shell's new thickness.
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "3 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - 3.0).abs() < 1e-9);
|
||||
let thick = (200.0 - 2.0 * bd(3.0), 120.0 - 2.0 * bd(3.0));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6), "{:?}", flat_size(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6));
|
||||
// The design file keeps the bend edges, and reopens to the same sheet.
|
||||
let saved = s.doc.to_json();
|
||||
assert!(saved.contains("\"bends\""), "{saved}");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(same_size(flat_size(&mut reopened), thick, 1e-6));
|
||||
// Bend edges came with format 17: a build from before them asks for an update.
|
||||
let version = serde_json::from_str::<Value>(&saved).unwrap()["format"].as_str().and_then(solidcraft_doc::format::version_of);
|
||||
assert!(version >= Some(17), "{version:?}");
|
||||
}
|
||||
|
||||
/// Designs from before bend edges open unchanged: a plate conversion saves without `bends`, as
|
||||
/// it always did, and a format 16 file with one is the same document and the same sheet.
|
||||
#[test]
|
||||
fn designs_from_before_bend_edges_open_unchanged() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Plate"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2]}));
|
||||
let mut older: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(!older.to_string().contains("\"bends\""), "{older}");
|
||||
older["format"] = json!("solidcraft/16");
|
||||
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
|
||||
assert!((volume(&mut reopened) - 100.0 * 60.0 * 2.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened), flat(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_edges_changed_in_an_edit_are_the_ones_bent() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
tray(&mut s, t);
|
||||
let made = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
// Only the two long walls: a channel as long as the tray, 60 + 2 x 30 less two deductions
|
||||
// across. (By the names of the first two edges it would be the front and the right wall.)
|
||||
run(&mut s, "timeline.edit", json!({"feature": made["feature"], "set": {"bends": [BOTTOM_EDGES[0], BOTTOM_EDGES[2]]}}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}");
|
||||
let across = 120.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (100.0, across), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// Its end walls are no faces of the sheet any more: the channel is open at both ends.
|
||||
let expect = t * 100.0 * ((60.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_and_cuts_work_on_a_converted_sheet() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let v0 = volume(&mut s);
|
||||
// A return flange on the front wall's top edge.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 30]], "height": 10, "angle": 90}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(v1 > v0 + 500.0, "{v0} {v1}");
|
||||
assert_eq!(bend_rows(&mut s).len(), 5);
|
||||
// A hole cut in the flat stays in the base when the tray is folded again.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "direction": "symmetric"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let hole = PI * 25.0 * 2.0;
|
||||
assert!((volume(&mut s) - (v1 - hole)).abs() < 2e-3 * v1, "{} vs {}", volume(&mut s), v1 - hole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_that_cannot_work_say_why() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Wide", 2.0, 0.44, 40.0, 0.5);
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
let err = |s: &mut Session, p: Value| s.execute("sheet.convert", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
// A bend that doesn't reach the fixed face through other bends.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[0, 0, 15]]}));
|
||||
assert!(e.contains("does not connect"), "{e}");
|
||||
// No edge there, and no face there.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[50, 30, 0]]}));
|
||||
assert!(e.contains("no edge"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, -20], "bends": BOTTOM_EDGES}));
|
||||
assert!(e.contains("no face"), "{e}");
|
||||
// A loop of bends.
|
||||
let mut bends: Vec<[f64; 3]> = BOTTOM_EDGES.to_vec();
|
||||
bends.push([0.0, 0.0, 15.0]);
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": bends}));
|
||||
assert!(e.contains("loop"), "{e}");
|
||||
// Walls shorter than their bends' setbacks (a 40 mm bend radius on 30 mm walls).
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "rule": "Wide"}));
|
||||
assert!(e.contains("setback"), "{e}");
|
||||
// The sheet's values are the rule's; an empty list of bends is not a plate.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "thickness": 3}));
|
||||
assert!(e.contains("the sheet metal rule sets it"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": []}));
|
||||
assert!(e.contains("1…1000"), "{e}");
|
||||
// Curved faces.
|
||||
let mut c = Session::default();
|
||||
run(&mut c, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
|
||||
let e = err(&mut c, json!({"body": "Rod", "face": [0, 0, 30], "bends": [[10, 0, 30]]}));
|
||||
assert!(e.contains("curved"), "{e}");
|
||||
// Nothing changed.
|
||||
assert!(s.model.state().sheets.is_empty() && c.model.state().sheets.is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user