Converted sheets take the rule's corner relief between walls and its bend relief where a bend ends beside more of the fixed face, and a wall on part of an edge bends only where it stands. Noted under format 21; nothing new is saved.
1724 lines
97 KiB
Rust
1724 lines
97 KiB
Rust
//! Convert to Sheet Metal: solid and shelled bodies with bend edges (with holes and round edges in
|
||
//! their faces), plates read from STEP files whose walls come in pieces, and plates with recesses.
|
||
|
||
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));
|
||
}
|
||
|
||
/// A channel 40 wide with walls 30 high (outside sizes), 2 thick and 100 long, its two corners
|
||
/// drawn as chamfers, as a model without bend radii draws a bend: 4 along each outer face and 2
|
||
/// along each inner one, the chords of an R 2 bend's outside (R + T) and inside (R).
|
||
fn chamfered_channel(s: &mut Session) {
|
||
let pts = [[0, 30], [0, 4], [4, 0], [36, 0], [40, 4], [40, 30], [38, 30], [38, 4], [36, 2], [4, 2], [2, 4], [2, 30]];
|
||
run(s, "sketch.create", json!({"plane": "XY", "name": "Channel"}));
|
||
run(s, "sketch.line", json!({"points": pts, "closed": true}));
|
||
run(s, "sketch.finish", json!({}));
|
||
run(s, "solid.extrude", json!({"sketch": "Channel", "distance": 100, "body_name": "Channel"}));
|
||
}
|
||
|
||
#[test]
|
||
fn a_chamfer_picked_in_its_middle_is_one_bend_at_the_virtual_sharp() {
|
||
// The rule's radius bends, not the one the chamfers stand for.
|
||
let (t, k, r) = (2.0, 0.5, 3.0);
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
chamfered_channel(&mut s);
|
||
// The middle of each outer chamfer.
|
||
run(&mut s, "sheet.convert", json!({"body": "Channel", "face": [20, 0, 50], "bends": [[2, 2, 50], [38, 2, 50]]}));
|
||
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:?}");
|
||
// The outside lengths to the virtual sharps less a 90° deduction each:
|
||
// 30 + 40 + 30 - 2 (2 (R + T) - π/2 (R + K T)) = 100 - 2 (10 - 2π) = 92.5664.
|
||
let across = 100.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||
assert!((across - 92.566371).abs() < 1e-6);
|
||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
// The envelope of the sharp corners stays; the chamfers are gone into the bends.
|
||
let (lo, hi) = bbox(&mut s);
|
||
assert!(
|
||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 40.0).abs() < 1e-6 && (hi[1] - 30.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 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0));
|
||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||
// The design keeps the chamfers' points and reopens to the same sheet.
|
||
let saved = s.doc.to_json();
|
||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||
assert!(same_size(flat_size(&mut reopened), (across, 100.0), 1e-6), "{:?}", flat_size(&mut reopened));
|
||
|
||
// Picked on the inner skin, from the inside of the base: the same sheet, bent down.
|
||
let mut b = Session::default();
|
||
rule(&mut b, "Test", t, k, r, 0.5);
|
||
chamfered_channel(&mut b);
|
||
run(&mut b, "sheet.convert", json!({"body": "Channel", "face": [20, 2, 50], "bends": [[3, 3, 50], [37, 3, 50]]}));
|
||
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b));
|
||
assert!(same_size(flat_size(&mut b), (across, 100.0), 1e-6), "{:?}", flat_size(&mut b));
|
||
assert!((volume(&mut b) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut b));
|
||
|
||
// Points on a chamfer's edges pick those edges, as before: four 45° bends with the
|
||
// chamfers as narrow faces of the sheet.
|
||
let mut e = Session::default();
|
||
rule(&mut e, "Test", t, k, r, 0.5);
|
||
chamfered_channel(&mut e);
|
||
run(&mut e, "sheet.convert", json!({"body": "Channel", "face": [20, 0, 50], "bends": [[0, 4, 50], [4, 0, 50], [36, 0, 50], [40, 4, 50]]}));
|
||
let rows = bend_rows(&mut e);
|
||
assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 45), "{rows:?}");
|
||
// A chamfer and one of its edges at once is refused.
|
||
let mut f = Session::default();
|
||
rule(&mut f, "Test", t, k, r, 0.5);
|
||
chamfered_channel(&mut f);
|
||
let both = f.execute("sheet.convert", &json!({"body": "Channel", "face": [20, 0, 50], "bends": [[2, 2, 50], [4, 0, 50]]}));
|
||
let msg = both.err().map(|x| x.to_string()).unwrap_or_default();
|
||
assert!(msg.contains("not both"), "{msg}");
|
||
}
|
||
|
||
/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line
|
||
/// runs, as [`section`]), extruded `len` along z as the body `Section`, its corners bent as a
|
||
/// sheet metal model draws them: an arc of radius `ri` on the inside of each bend and one of
|
||
/// `ri + t` on the outside, about one axis. Returns, per corner, a point in the middle of its
|
||
/// outer arc and one in the middle of its inner arc.
|
||
fn bent_section(s: &mut Session, outer: &[(f64, f64)], t: f64, ri: f64, len: f64) -> Vec<([f64; 3], [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 left = |d: (f64, f64)| (-d.1, d.0);
|
||
let at = |p: (f64, f64), d: (f64, f64), k: f64| (p.0 + d.0 * k, p.1 + d.1 * k);
|
||
// Per corner: where its arc starts and ends on the line's skin and on the other skin, the
|
||
// middles of those two arcs, and the middles of its outer and its inner arc.
|
||
let mut corners = Vec::new();
|
||
for (i, &c) in outer.iter().enumerate().take(n - 1).skip(1) {
|
||
let (d0, d1) = (dir(i - 1), dir(i));
|
||
let (cross, dot) = (d0.0 * d1.1 - d0.1 * d1.0, d0.0 * d1.0 + d0.1 * d1.1);
|
||
let theta = cross.abs().atan2(dot);
|
||
// Turning toward the material, this skin is the bend's outside.
|
||
let (rp, ro) = if cross > 0.0 { (ri + t, ri) } else { (ri, ri + t) };
|
||
let a = at(c, d0, -rp * (theta / 2.0).tan());
|
||
let b = at(c, d1, rp * (theta / 2.0).tan());
|
||
let centre = at(a, left(d0), if cross > 0.0 { rp } else { -rp });
|
||
let (ai, bi) = (at(a, left(d0), t), at(b, left(d1), t));
|
||
let l = (c.0 - centre.0).hypot(c.1 - centre.1);
|
||
let mid = |r: f64| (centre.0 + (c.0 - centre.0) / l * r, centre.1 + (c.1 - centre.1) / l * r);
|
||
corners.push((a, b, ai, bi, mid(rp), mid(ro), (mid(ri + t), mid(ri))));
|
||
}
|
||
let xy = |p: (f64, f64)| [p.0, p.1];
|
||
run(s, "sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||
let mut from = outer[0];
|
||
for &(a, b, _, _, m, ..) in &corners {
|
||
run(s, "sketch.line", json!({"points": [xy(from), xy(a)]}));
|
||
run(s, "sketch.arc.three_point", json!({"start": xy(a), "end": xy(b), "through": xy(m)}));
|
||
from = b;
|
||
}
|
||
let (last, first) = (at(outer[n - 1], left(dir(n - 2)), t), at(outer[0], left(dir(0)), t));
|
||
run(s, "sketch.line", json!({"points": [xy(from), xy(outer[n - 1]), xy(last)]}));
|
||
from = last;
|
||
for &(_, _, ai, bi, _, m, _) in corners.iter().rev() {
|
||
run(s, "sketch.line", json!({"points": [xy(from), xy(bi)]}));
|
||
run(s, "sketch.arc.three_point", json!({"start": xy(bi), "end": xy(ai), "through": xy(m)}));
|
||
from = ai;
|
||
}
|
||
run(s, "sketch.line", json!({"points": [xy(from), xy(first), xy(outer[0])]}));
|
||
run(s, "sketch.finish", json!({}));
|
||
run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"}));
|
||
let z = len / 2.0;
|
||
corners.iter().map(|&(.., (m, mi))| ([m.0, m.1, z], [mi.0, mi.1, z])).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_bend_modelled_with_its_radius_is_one_bend_of_the_rules_radius() {
|
||
// An L: a base 60 and a leg 40 to the sharp corner outside, 2 thick and 100 long, its
|
||
// corner bent R 2 inside and R 4 outside. The rule bends R 3: its radius, not the model's.
|
||
let (t, k, r) = (2.0, 0.5, 3.0);
|
||
let l = |s: &mut Session| {
|
||
rule(s, "Test", t, k, r, 0.5);
|
||
bent_section(s, &[(0.0, 40.0), (0.0, 0.0), (60.0, 0.0)], t, 2.0, 100.0)[0]
|
||
};
|
||
let mut s = Session::default();
|
||
let (outer, inner) = l(&mut s);
|
||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": [outer]}));
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == "up" && (rows[0].2 - 100.0).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 outside lengths to the virtual sharp less one 90° deduction:
|
||
// 60 + 40 - (2 (R + T) - π/2 (R + K T)) = 100 - (10 - 2π) = 96.2832.
|
||
let across = 100.0 - deduction(FRAC_PI_2, r, t, k);
|
||
assert!((across - 96.283185).abs() < 1e-6);
|
||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
// The envelope of the sharp corner stays; the round is the rule's, R + T outside.
|
||
let (lo, hi) = bbox(&mut s);
|
||
assert!(
|
||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 60.0).abs() < 1e-6 && (hi[1] - 40.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
|
||
"{lo:?} {hi:?}"
|
||
);
|
||
// Two flat strips and a quarter round on the neutral radius R + T/2.
|
||
let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t)) + FRAC_PI_2 * (r + t / 2.0));
|
||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||
// The design keeps the cylinder's point and reopens to the same sheet.
|
||
let saved = s.doc.to_json();
|
||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||
assert!(same_size(flat_size(&mut reopened), (across, 100.0), 1e-6), "{:?}", flat_size(&mut reopened));
|
||
|
||
// The inner cylinder is the same bend; and from the inside of the base, the sheet's other
|
||
// skin is the fixed one, the bend turning down from it, picked on either cylinder: the
|
||
// inside lengths to the inner sharp, 58 + 38, less 2 R plus the allowance, the same blank.
|
||
assert!((58.0 + 38.0 - 2.0 * r + allowance - across).abs() < 1e-9);
|
||
for (face, bend, direction) in [([30.0, 0.0, 50.0], inner, "up"), ([30.0, 2.0, 50.0], outer, "down"), ([30.0, 2.0, 50.0], inner, "down")] {
|
||
let mut b = Session::default();
|
||
l(&mut b);
|
||
run(&mut b, "sheet.convert", json!({"body": "Section", "face": face, "bends": [bend]}));
|
||
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == direction), "{face:?} {bend:?}: {:?}", bend_rows(&mut b));
|
||
assert!(same_size(flat_size(&mut b), (across, 100.0), 1e-6), "{face:?} {bend:?}: {:?}", flat_size(&mut b));
|
||
assert!((volume(&mut b) - expect).abs() < 2e-3 * expect, "{face:?} {bend:?}: {} vs {expect}", volume(&mut b));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_channel_with_its_bends_modelled_folds_back_into_itself_under_its_own_radius() {
|
||
// A channel 40 wide with walls 30 high to the sharp corners outside, 2 thick and 100 long,
|
||
// bent R 2 inside, R 4 outside. Under a rule of that radius the sheet is the model.
|
||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||
let channel = |s: &mut Session| {
|
||
rule(s, "Test", t, k, r, 0.5);
|
||
bent_section(s, &[(0.0, 30.0), (0.0, 0.0), (40.0, 0.0), (40.0, 30.0)], t, r, 100.0)
|
||
};
|
||
let mut s = Session::default();
|
||
let corners = channel(&mut s);
|
||
let solid = volume(&mut s);
|
||
// Two flat walls, the base and two quarter rounds on the neutral radius R + T/2.
|
||
let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0));
|
||
assert!((solid - expect).abs() < 2e-3 * expect, "{solid} vs {expect}");
|
||
// One bend picked on its outer cylinder, the other on its inner one.
|
||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [20, 0, 50], "bends": [corners[0].0, corners[1].1]}));
|
||
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:?}");
|
||
// 30 + 40 + 30 less two deductions, 2 (R + T) - π/2 (R + K T) each.
|
||
let across = 100.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
// The same solid as before: its volume and its box.
|
||
assert!((volume(&mut s) - solid).abs() < 1e-3 * solid, "{} vs {solid}", volume(&mut s));
|
||
let (lo, hi) = bbox(&mut s);
|
||
assert!(
|
||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 40.0).abs() < 1e-6 && (hi[1] - 30.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
|
||
"{lo:?} {hi:?}"
|
||
);
|
||
// Unfold and refold keep it.
|
||
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]), (hi[1] - lo[1], hi[2] - lo[2])];
|
||
assert!(flat_box.iter().any(|b| same_size(*b, (across, 100.0), 1e-6)), "{lo:?} {hi:?}");
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - solid).abs() < 1e-3 * solid);
|
||
}
|
||
|
||
#[test]
|
||
fn a_filleted_edge_of_a_solid_block_is_a_bend() {
|
||
// A block's bottom edge rounded R 5: the faces either side meet at the old sharp edge,
|
||
// the rule's thickness taken inward, an L with a 100 base and a 30 wall outside.
|
||
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, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"}));
|
||
run(&mut s, "solid.fillet", json!({"edges": [[100, 30, 0]], "radius": 5}));
|
||
let h = 5.0 * (1.0 - 0.5f64.sqrt());
|
||
run(&mut s, "sheet.convert", json!({"body": "Block", "face": [50, 30, 0], "bends": [[100.0 - h, 30.0, h]]}));
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == "up" && (rows[0].2 - 60.0).abs() < 1e-6, "{rows:?}");
|
||
let across = 130.0 - deduction(FRAC_PI_2, r, t, k);
|
||
assert!(same_size(flat_size(&mut s), (across, 60.0), 1e-6), "{:?} vs {across}", flat_size(&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());
|
||
}
|
||
|
||
/// One side face of a plate written to STEP: flat from `a` to `b`, or a piece of a round wall
|
||
/// about `c` from `a` to `b` (counter-clockwise seen from +z when `ccw`).
|
||
#[derive(Clone, Copy)]
|
||
enum Wall {
|
||
Flat([f64; 2], [f64; 2]),
|
||
Round([f64; 2], [f64; 2], [f64; 2], bool),
|
||
}
|
||
|
||
impl Wall {
|
||
fn ends(&self) -> ([f64; 2], [f64; 2]) {
|
||
match *self {
|
||
Wall::Flat(a, b) | Wall::Round(a, b, _, _) => (a, b),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A round hole of radius `r` about `c` (clockwise seen from +z), its wall in `pieces` cylinder
|
||
/// faces, the first seam at `seam` degrees.
|
||
fn hole(c: [f64; 2], r: f64, pieces: usize, seam: f64) -> Vec<Wall> {
|
||
let at = |i: usize| {
|
||
let a = (seam - 360.0 * i as f64 / pieces as f64).to_radians();
|
||
[c[0] + r * a.cos(), c[1] + r * a.sin()]
|
||
};
|
||
(0..pieces).map(|i| Wall::Round(at(i), at(i + 1), c, false)).collect()
|
||
}
|
||
|
||
/// A rectangle (counter-clockwise), its bottom side split in two faces when `split`.
|
||
fn rectangle(w: f64, h: f64, split: bool) -> Vec<Wall> {
|
||
let mut walls = if split {
|
||
vec![Wall::Flat([0.0, 0.0], [w / 2.0, 0.0]), Wall::Flat([w / 2.0, 0.0], [w, 0.0])]
|
||
} else {
|
||
vec![Wall::Flat([0.0, 0.0], [w, 0.0])]
|
||
};
|
||
walls.extend([Wall::Flat([w, 0.0], [w, h]), Wall::Flat([w, h], [0.0, h]), Wall::Flat([0.0, h], [0.0, 0.0])]);
|
||
walls
|
||
}
|
||
|
||
/// The entities of a STEP file, numbered as they are added; vertices and vertical edges once.
|
||
#[derive(Default)]
|
||
struct Step {
|
||
lines: Vec<String>,
|
||
vertices: Vec<([i64; 3], usize)>,
|
||
verticals: Vec<([i64; 2], usize)>,
|
||
}
|
||
|
||
impl Step {
|
||
fn add(&mut self, entity: String) -> usize {
|
||
self.lines.push(format!("#{}={entity};", self.lines.len() + 1));
|
||
self.lines.len()
|
||
}
|
||
|
||
fn point(&mut self, p: [f64; 3]) -> usize {
|
||
self.add(format!("CARTESIAN_POINT('',({:.12},{:.12},{:.12}))", p[0], p[1], p[2]))
|
||
}
|
||
|
||
fn frame(&mut self, o: [f64; 3], z: [f64; 3], x: [f64; 3]) -> usize {
|
||
let o = self.point(o);
|
||
let z = self.add(format!("DIRECTION('',({:.12},{:.12},{:.12}))", z[0], z[1], z[2]));
|
||
let x = self.add(format!("DIRECTION('',({:.12},{:.12},{:.12}))", x[0], x[1], x[2]));
|
||
self.add(format!("AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})"))
|
||
}
|
||
|
||
fn vertex(&mut self, p: [f64; 2], z: f64) -> usize {
|
||
let key = [p[0], p[1], z].map(|v| (v * 1e9).round() as i64);
|
||
if let Some((_, v)) = self.vertices.iter().find(|(k, _)| *k == key) {
|
||
return *v;
|
||
}
|
||
let point = self.point([p[0], p[1], z]);
|
||
let v = self.add(format!("VERTEX_POINT('',#{point})"));
|
||
self.vertices.push((key, v));
|
||
v
|
||
}
|
||
|
||
fn line(&mut self, a: [f64; 3], b: [f64; 3], va: usize, vb: usize) -> usize {
|
||
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||
let p = self.point(a);
|
||
let dir = self.add(format!("DIRECTION('',({:.12},{:.12},{:.12}))", d[0] / l, d[1] / l, d[2] / l));
|
||
let v = self.add(format!("VECTOR('',#{dir},{l:.12})"));
|
||
let line = self.add(format!("LINE('',#{p},#{v})"));
|
||
self.add(format!("EDGE_CURVE('',#{va},#{vb},#{line},.T.)"))
|
||
}
|
||
|
||
/// The wall's edge at height `z`.
|
||
fn edge(&mut self, wall: Wall, z: f64) -> usize {
|
||
let (a, b) = wall.ends();
|
||
let (va, vb) = (self.vertex(a, z), self.vertex(b, z));
|
||
match wall {
|
||
Wall::Flat(..) => self.line([a[0], a[1], z], [b[0], b[1], z], va, vb),
|
||
Wall::Round(_, _, c, ccw) => {
|
||
let frame = self.frame([c[0], c[1], z], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
|
||
let r = (a[0] - c[0]).hypot(a[1] - c[1]);
|
||
let circle = self.add(format!("CIRCLE('',#{frame},{r:.12})"));
|
||
self.add(format!("EDGE_CURVE('',#{va},#{vb},#{circle},{})", if ccw { ".T." } else { ".F." }))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn vertical(&mut self, p: [f64; 2], t: f64) -> usize {
|
||
let key = p.map(|v| (v * 1e9).round() as i64);
|
||
if let Some((_, e)) = self.verticals.iter().find(|(k, _)| *k == key) {
|
||
return *e;
|
||
}
|
||
let (v0, v1) = (self.vertex(p, 0.0), self.vertex(p, t));
|
||
let e = self.line([p[0], p[1], 0.0], [p[0], p[1], t], v0, v1);
|
||
self.verticals.push((key, e));
|
||
e
|
||
}
|
||
|
||
fn edge_loop(&mut self, edges: &[(usize, bool)]) -> usize {
|
||
let oriented: Vec<String> = edges
|
||
.iter()
|
||
.map(|(e, forward)| format!("#{}", self.add(format!("ORIENTED_EDGE('',*,*,#{e},{})", if *forward { ".T." } else { ".F." }))))
|
||
.collect();
|
||
self.add(format!("EDGE_LOOP('',({}))", oriented.join(",")))
|
||
}
|
||
}
|
||
|
||
/// STEP text of a plate `t` thick standing on z = 0, its outline `loops` (the outer one
|
||
/// counter-clockwise, holes clockwise, seen from +z) with one side face per wall: a plane, or a
|
||
/// cylinder piece. Hand-written, the way exporters that split closed faces write them.
|
||
fn plate_step(loops: &[Vec<Wall>], t: f64) -> String {
|
||
let mut s = Step::default();
|
||
let bottom: Vec<Vec<usize>> = loops.iter().map(|l| l.iter().map(|w| s.edge(*w, 0.0)).collect()).collect();
|
||
let top: Vec<Vec<usize>> = loops.iter().map(|l| l.iter().map(|w| s.edge(*w, t)).collect()).collect();
|
||
let mut faces = Vec::new();
|
||
for (i, walls) in loops.iter().enumerate() {
|
||
for (j, wall) in walls.iter().enumerate() {
|
||
let (a, b) = wall.ends();
|
||
let (va, vb) = (s.vertical(a, t), s.vertical(b, t));
|
||
let lp = s.edge_loop(&[(bottom[i][j], true), (vb, true), (top[i][j], false), (va, false)]);
|
||
let bound = s.add(format!("FACE_OUTER_BOUND('',#{lp},.T.)"));
|
||
let (surface, sense) = match *wall {
|
||
Wall::Flat(a, b) => {
|
||
let l = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||
let (dx, dy) = ((b[0] - a[0]) / l, (b[1] - a[1]) / l);
|
||
let frame = s.frame([a[0], a[1], 0.0], [dy, -dx, 0.0], [dx, dy, 0.0]);
|
||
(s.add(format!("PLANE('',#{frame})")), true)
|
||
}
|
||
Wall::Round(a, _, c, ccw) => {
|
||
let frame = s.frame([c[0], c[1], 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
|
||
let r = (a[0] - c[0]).hypot(a[1] - c[1]);
|
||
(s.add(format!("CYLINDRICAL_SURFACE('',#{frame},{r:.12})")), ccw)
|
||
}
|
||
};
|
||
faces.push(s.add(format!("ADVANCED_FACE('',(#{bound}),#{surface},{})", if sense { ".T." } else { ".F." })));
|
||
}
|
||
}
|
||
for (z, edges, up) in [(t, &top, true), (0.0, &bottom, false)] {
|
||
let mut bounds = Vec::new();
|
||
for (i, es) in edges.iter().enumerate() {
|
||
let walk: Vec<(usize, bool)> = if up { es.iter().map(|e| (*e, true)).collect() } else { es.iter().rev().map(|e| (*e, false)).collect() };
|
||
let lp = s.edge_loop(&walk);
|
||
bounds.push(format!("#{}", s.add(format!("{}('',#{lp},.T.)", if i == 0 { "FACE_OUTER_BOUND" } else { "FACE_BOUND" }))));
|
||
}
|
||
let frame = s.frame([0.0, 0.0, z], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
|
||
let plane = s.add(format!("PLANE('',#{frame})"));
|
||
faces.push(s.add(format!("ADVANCED_FACE('',({}),#{plane},{})", bounds.join(","), if up { ".T." } else { ".F." })));
|
||
}
|
||
let faces: Vec<String> = faces.iter().map(|f| format!("#{f}")).collect();
|
||
let shell = s.add(format!("CLOSED_SHELL('',({}))", faces.join(",")));
|
||
let brep = s.add(format!("MANIFOLD_SOLID_BREP('Plate',#{shell})"));
|
||
s.add(format!("SHAPE_REPRESENTATION('',(#{brep}),$)"));
|
||
format!(
|
||
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('plate','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));\nENDSEC;\nDATA;\n{}\nENDSEC;\nEND-ISO-10303-21;\n",
|
||
s.lines.join("\n")
|
||
)
|
||
}
|
||
|
||
/// The plate inserted from a STEP file and converted, picked on its top face at `pick`.
|
||
fn convert_step_plate(tag: &str, loops: &[Vec<Wall>], t: f64, pick: [f64; 2]) -> Session {
|
||
let path = std::env::temp_dir().join(format!("solidcraft-split-walls-{tag}-{}.step", std::process::id()));
|
||
std::fs::write(&path, plate_step(loops, t)).unwrap();
|
||
let mut s = Session::default();
|
||
run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}));
|
||
let _ = std::fs::remove_file(&path);
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [pick[0], pick[1], t]}));
|
||
s
|
||
}
|
||
|
||
/// The flat pattern's cut-outs that are whole circles, as (radius, centre).
|
||
fn flat_circles(s: &mut Session) -> Vec<(f64, [f64; 2])> {
|
||
let shapes = flat(s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
|
||
let c = |v: &Value| [v["center"][0].as_f64().unwrap_or(f64::NAN), v["center"][1].as_f64().unwrap_or(f64::NAN)];
|
||
shapes.iter().filter_map(|x| x.get("circle")).map(|x| (x["radius"].as_f64().unwrap_or(f64::NAN), c(x))).collect()
|
||
}
|
||
|
||
/// How many entities of each kind the flat pattern's DXF has.
|
||
fn dxf_entities(s: &mut Session) -> (usize, usize, usize) {
|
||
let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or("").replace("\r\n", "\n");
|
||
// Group codes may be padded (` 0`): compare the code and value lines trimmed.
|
||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||
let n = |kind: &str| lines.chunks(2).filter(|pair| pair == &["0", kind]).count();
|
||
(n("LINE"), n("ARC"), n("CIRCLE"))
|
||
}
|
||
|
||
/// A plate whose round holes' walls are two half-cylinders each (as many exporters write a hole)
|
||
/// converts with the holes as circles. It was refused as "not a plate of even thickness": its
|
||
/// holes came out as polygons, whose area missed the holes' by more than the plate check allows.
|
||
#[test]
|
||
fn a_plate_whose_hole_walls_are_half_cylinders_converts_with_round_holes() {
|
||
let (w, t, r) = (30.0, 2.0, 2.0);
|
||
let centres = [[6.0, 6.0], [24.0, 6.0], [24.0, 24.0], [6.0, 24.0]];
|
||
let mut loops = vec![rectangle(w, w, false)];
|
||
loops.extend(centres.iter().map(|c| hole(*c, r, 2, 90.0)));
|
||
let mut s = convert_step_plate("halves", &loops, t, [15.0, 15.0]);
|
||
let want = (w * w - 4.0 * PI * r * r) * t;
|
||
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
|
||
let circles = flat_circles(&mut s);
|
||
assert_eq!(circles.len(), 4, "{circles:?}");
|
||
assert!(circles.iter().all(|(radius, _)| (radius - r).abs() < 1e-9), "{circles:?}");
|
||
// The holes keep their places: 18 apart along the sides, 18·√2 across.
|
||
let d = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).hypot(a[1] - b[1]);
|
||
let mut gaps: Vec<f64> = (0..4).flat_map(|i| (i + 1..4).map(move |j| (i, j))).map(|(i, j)| d(circles[i].1, circles[j].1)).collect();
|
||
gaps.sort_by(f64::total_cmp);
|
||
assert!(gaps[..4].iter().all(|g| (g - 18.0).abs() < 1e-9) && gaps[4..].iter().all(|g| (g - 18.0 * SQRT_2).abs() < 1e-9), "{gaps:?}");
|
||
// The DXF: the outline's four lines and one circle per hole.
|
||
assert_eq!(dxf_entities(&mut s), (4, 0, 4));
|
||
}
|
||
|
||
/// Holes of any radius whose walls come in two, three or four pieces, seams anywhere: each is one
|
||
/// circle.
|
||
#[test]
|
||
fn hole_walls_in_any_number_of_pieces_each_make_one_circle() {
|
||
let (t, holes) = (2.0, [([20.0, 30.0], 5.0, 2, 0.0), ([50.0, 30.0], 3.0, 3, 37.0), ([80.0, 30.0], 8.0, 4, 10.0)]);
|
||
let mut loops = vec![rectangle(100.0, 60.0, false)];
|
||
loops.extend(holes.iter().map(|(c, r, pieces, seam)| hole(*c, *r, *pieces, *seam)));
|
||
let mut s = convert_step_plate("pieces", &loops, t, [35.0, 10.0]);
|
||
let want = (6000.0 - PI * (25.0 + 9.0 + 64.0)) * t;
|
||
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
|
||
let mut radii: Vec<f64> = flat_circles(&mut s).iter().map(|c| c.0).collect();
|
||
radii.sort_by(f64::total_cmp);
|
||
assert!(radii.len() == 3 && radii.iter().zip([3.0, 5.0, 8.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{radii:?}");
|
||
assert_eq!(dxf_entities(&mut s), (4, 0, 3));
|
||
}
|
||
|
||
/// Other walls such files split: rounded corners in two pieces each, a slot whose round ends are
|
||
/// in two pieces each, a straight side in two faces. Each corner and slot end is one arc, the
|
||
/// side one line.
|
||
#[test]
|
||
fn rounded_corners_slot_ends_and_split_sides_convert_as_single_arcs_and_lines() {
|
||
let (t, rc, rs, rh) = (2.0, 6.0, 4.0, 5.0);
|
||
let quarter = |c: [f64; 2], from: f64, pieces: usize| -> Vec<Wall> {
|
||
let at = |i: usize| {
|
||
let a = (from + 90.0 * i as f64 / pieces as f64).to_radians();
|
||
[c[0] + rc * a.cos(), c[1] + rc * a.sin()]
|
||
};
|
||
(0..pieces).map(|i| Wall::Round(at(i), at(i + 1), c, true)).collect()
|
||
};
|
||
// 100 x 60, corners rounded by 6, the bottom side in two faces.
|
||
let mut outer = vec![Wall::Flat([rc, 0.0], [50.0, 0.0]), Wall::Flat([50.0, 0.0], [100.0 - rc, 0.0])];
|
||
outer.extend(quarter([100.0 - rc, rc], -90.0, 2));
|
||
outer.push(Wall::Flat([100.0, rc], [100.0, 60.0 - rc]));
|
||
outer.extend(quarter([100.0 - rc, 60.0 - rc], 0.0, 2));
|
||
outer.push(Wall::Flat([100.0 - rc, 60.0], [rc, 60.0]));
|
||
outer.extend(quarter([rc, 60.0 - rc], 90.0, 2));
|
||
outer.push(Wall::Flat([0.0, 60.0 - rc], [0.0, rc]));
|
||
outer.extend(quarter([rc, rc], 180.0, 2));
|
||
// A slot 30 between its end centres, 8 wide, each end in two pieces (clockwise).
|
||
let (a, b) = ([45.0, 30.0], [75.0, 30.0]);
|
||
let slot = vec![
|
||
Wall::Flat([a[0], a[1] + rs], [b[0], b[1] + rs]),
|
||
Wall::Round([b[0], b[1] + rs], [b[0] + rs, b[1]], b, false),
|
||
Wall::Round([b[0] + rs, b[1]], [b[0], b[1] - rs], b, false),
|
||
Wall::Flat([b[0], b[1] - rs], [a[0], a[1] - rs]),
|
||
Wall::Round([a[0], a[1] - rs], [a[0] - rs, a[1]], a, false),
|
||
Wall::Round([a[0] - rs, a[1]], [a[0], a[1] + rs], a, false),
|
||
];
|
||
let loops = vec![outer, slot, hole([20.0, 30.0], rh, 2, 90.0)];
|
||
let mut s = convert_step_plate("sides", &loops, t, [20.0, 50.0]);
|
||
let area = 6000.0 - (4.0 - PI) * rc * rc - (30.0 * 2.0 * rs + PI * rs * rs) - PI * rh * rh;
|
||
assert!((volume(&mut s) - area * t).abs() < 1e-4 * area * t, "{} vs {}", volume(&mut s), area * t);
|
||
// The outline: four lines and four quarter arcs; the slot: two lines and two half arcs; the
|
||
// hole: a circle.
|
||
assert_eq!(dxf_entities(&mut s), (6, 6, 1));
|
||
let shapes = flat(&mut s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
|
||
let slot = shapes.iter().find_map(|x| x["segments"].as_array().cloned()).unwrap_or_default();
|
||
let sweeps: Vec<f64> = slot.iter().filter_map(|x| x["arc"]["sweep_deg"].as_f64()).map(f64::abs).collect();
|
||
assert!(slot.len() == 4 && sweeps.len() == 2 && sweeps.iter().all(|x| (x - 180.0).abs() < 1e-6), "{slot:?}");
|
||
}
|
||
|
||
/// Holes drilled here come out as circles too (their walls are half-cylinders as well), and a
|
||
/// plate with many small ones converts: nine 4 mm holes in a 30 x 30 x 2 plate were refused too.
|
||
#[test]
|
||
fn a_plate_with_holes_drilled_here_converts_with_round_holes() {
|
||
let (w, t, r) = (30.0, 2.0, 2.0);
|
||
let mut s = Session::default();
|
||
run(&mut s, "solid.box", json!({"length": w, "width": w, "height": t, "body_name": "Plate"}));
|
||
for x in [6.0, 15.0, 24.0] {
|
||
for y in [6.0, 15.0, 24.0] {
|
||
run(&mut s, "solid.hole", json!({"position": [x, y, t], "diameter": 2.0 * r}));
|
||
}
|
||
}
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [10.5, 10.5, t]}));
|
||
let want = (w * w - 9.0 * PI * r * r) * t;
|
||
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
|
||
let circles = flat_circles(&mut s);
|
||
assert!(circles.len() == 9 && circles.iter().all(|(radius, _)| (radius - r).abs() < 1e-9), "{circles:?}");
|
||
assert_eq!(dxf_entities(&mut s), (4, 0, 9));
|
||
}
|
||
|
||
/// A box `l` x `w` x `t` with round holes (centre, diameter) drilled through it, converted,
|
||
/// picked on its top face.
|
||
fn convert_box(l: f64, w: f64, t: f64, holes: &[([f64; 2], f64)]) -> Session {
|
||
let mut s = Session::default();
|
||
run(&mut s, "solid.box", json!({"length": l, "width": w, "height": t, "body_name": "Plate"}));
|
||
for (c, d) in holes {
|
||
run(&mut s, "solid.hole", json!({"position": [c[0], c[1], t], "diameter": d}));
|
||
}
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [l * 0.4, w * 0.25, t]}));
|
||
s
|
||
}
|
||
|
||
/// A long thin strip converts. 1200 x 100 x 1 was refused with "no outline on that face": the
|
||
/// outline's tolerance was a thousandth of the body's size (1.2), more than the thickness, so the
|
||
/// far side's triangles counted as the picked face's and no boundary was left.
|
||
#[test]
|
||
fn a_long_thin_strip_converts() {
|
||
let mut s = convert_box(1200.0, 100.0, 1.0, &[]);
|
||
assert!((volume(&mut s) - 120_000.0).abs() < 1e-6 * 120_000.0, "{}", volume(&mut s));
|
||
assert!(same_size(flat_size(&mut s), (1200.0, 100.0), 1e-6), "{:?}", flat_size(&mut s));
|
||
let t = flat(&mut s)["flat_size_mm"][2].as_f64().unwrap_or(f64::NAN);
|
||
assert!((t - 1.0).abs() < 1e-9, "{t}");
|
||
assert_eq!(dxf_entities(&mut s), (4, 0, 0));
|
||
}
|
||
|
||
/// Plates further past that, a thousandth of their size two to six times their thickness: a
|
||
/// full sheet, a foil, a long bar.
|
||
#[test]
|
||
fn plates_thousands_of_times_longer_than_thick_convert() {
|
||
for (l, w, t) in [(3000.0, 1500.0, 0.5), (400.0, 100.0, 0.2), (6000.0, 20.0, 1.0)] {
|
||
let mut s = convert_box(l, w, t, &[]);
|
||
let want = l * w * t;
|
||
assert!((volume(&mut s) - want).abs() < 1e-6 * want, "{l} x {w} x {t}: {} vs {want}", volume(&mut s));
|
||
assert!(same_size(flat_size(&mut s), (l, w), 1e-6), "{l} x {w} x {t}: {:?}", flat_size(&mut s));
|
||
assert_eq!(dxf_entities(&mut s), (4, 0, 0), "{l} x {w} x {t}");
|
||
}
|
||
}
|
||
|
||
/// The strip with round holes down to its thickness across keeps every one as a circle. At 1200
|
||
/// long it was refused; just under the old limit, 990 long, it converted without the 1 mm hole:
|
||
/// the outline's chords could sag 0.99, more than that hole's radius, and it fell out.
|
||
#[test]
|
||
fn a_long_thin_strip_keeps_its_small_round_holes() {
|
||
let d = [10.0, 4.0, 2.0, 1.0];
|
||
for l in [990.0, 1200.0] {
|
||
let holes: Vec<([f64; 2], f64)> = d.iter().enumerate().map(|(i, d)| ([l * (i as f64 + 1.0) / 5.0, 50.0], *d)).collect();
|
||
let mut s = convert_box(l, 100.0, 1.0, &holes);
|
||
let want = l * 100.0 - PI / 4.0 * d.iter().map(|d| d * d).sum::<f64>();
|
||
assert!((volume(&mut s) - want).abs() < 1e-5 * want, "{l}: {} vs {want}", volume(&mut s));
|
||
let mut radii: Vec<f64> = flat_circles(&mut s).iter().map(|c| c.0).collect();
|
||
radii.sort_by(f64::total_cmp);
|
||
assert!(radii.len() == 4 && radii.iter().zip([0.5, 1.0, 2.0, 5.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{l}: {radii:?}");
|
||
assert_eq!(dxf_entities(&mut s), (4, 0, 4), "{l}");
|
||
}
|
||
}
|
||
|
||
/// A 60 x 40 x 1 plate on XY.
|
||
fn plate(s: &mut Session) {
|
||
run(s, "solid.box", json!({"length": 60, "width": 40, "height": 1, "body_name": "Plate"}));
|
||
}
|
||
|
||
/// The plate with what `draw` sketches on its top cut `depth` deep into it (the profiles at
|
||
/// `profiles`, all when none).
|
||
fn engraved_plate(s: &mut Session, depth: f64, draw: impl Fn(&mut Session), profiles: &[[f64; 2]]) {
|
||
plate(s);
|
||
run(s, "sketch.create", json!({"plane": "XY", "offset": 1, "name": "Label"}));
|
||
draw(s);
|
||
run(s, "sketch.finish", json!({}));
|
||
let mut cut = json!({"sketch": "Label", "distance": depth, "direction": "negative", "operation": "cut"});
|
||
if !profiles.is_empty() {
|
||
cut["profiles"] = profiles.iter().map(|p| json!({"point": p})).collect();
|
||
}
|
||
run(s, "solid.extrude", cut);
|
||
}
|
||
|
||
fn label_rectangle(s: &mut Session) {
|
||
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 20]}));
|
||
}
|
||
|
||
fn contains(s: &Session, p: [f64; 3]) -> bool {
|
||
s.model.state().bodies.first().is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
|
||
}
|
||
|
||
/// The LINE entities a DXF draws on `layer` (group codes and values alternate, a line each).
|
||
fn lines_on(dxf: &str, layer: &str) -> usize {
|
||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||
let mut in_line = false;
|
||
let mut count = 0;
|
||
for pair in lines.chunks(2) {
|
||
match pair {
|
||
["0", kind] => in_line = *kind == "LINE",
|
||
["8", name] if in_line && *name == layer => count += 1,
|
||
_ => {}
|
||
}
|
||
}
|
||
count
|
||
}
|
||
|
||
/// A plate with an engraving converts: the engraving is a pocket of the sheet, so the sheet keeps
|
||
/// the plate's shape, and its outline is the flat pattern's pocket, on the DXF's pocket layer.
|
||
/// Picked on the engraved face, the pocket starts from the sheet's z = 0 (the face picked);
|
||
/// picked on the other face, it ends at z = T.
|
||
#[test]
|
||
fn a_plate_with_an_engraving_converts_with_the_engraving_as_a_pocket() {
|
||
for (pick, layer) in [([45.0, 30.0, 1.0], "POCKET_BOTTOM_Z0.000000_TO_0.100000_MM"), ([45.0, 30.0, 0.0], "POCKET_TOP_Z0.900000_TO_1.000000_MM")] {
|
||
let mut s = Session::default();
|
||
engraved_plate(&mut s, 0.1, label_rectangle, &[]);
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": pick}));
|
||
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{pick:?}: {:?}", s.model.results);
|
||
// By hand: 60·40·1 less the engraving's 20·10·0.1; the faces' 2·60·40 + 2·(60 + 40)·1 and
|
||
// the engraving's walls, 2·(20 + 10)·0.1.
|
||
let m = run(&mut s, "inspect.measure", json!({}));
|
||
assert!((m["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) - 2380.0).abs() < 1e-6, "{pick:?}: {m}");
|
||
assert!((m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN) - 5006.0).abs() < 1e-6, "{pick:?}: {m}");
|
||
// It stays in the face it was cut into (the converted plate is as thick, wherever it lies).
|
||
let (lo, hi) = bbox(&mut s);
|
||
assert!((hi[2] - lo[2] - 1.0).abs() < 1e-9, "{pick:?}: {lo:?} {hi:?}");
|
||
assert!(!contains(&s, [20.0, 15.0, hi[2] - 0.05]) && contains(&s, [20.0, 15.0, hi[2] - 0.15]), "{pick:?}");
|
||
assert!(contains(&s, [45.0, 30.0, hi[2] - 0.05]), "{pick:?}");
|
||
// The flat pattern: the whole plate, no cut-out, and the engraving as a pocket 0.1 deep.
|
||
let f = flat(&mut s);
|
||
assert!(same_size(flat_size(&mut s), (60.0, 40.0), 1e-9), "{f}");
|
||
assert_eq!(f["cutouts"], json!([]), "{f}");
|
||
let pockets = f["pockets"].as_array().cloned().unwrap_or_default();
|
||
assert_eq!(pockets.len(), 1, "{f}");
|
||
assert!((pockets[0]["depth_mm"].as_f64().unwrap_or(f64::NAN) - 0.1).abs() < 1e-9, "{f}");
|
||
let corners: Vec<(f64, f64)> =
|
||
pockets[0]["outline"].as_array().unwrap().iter().map(|p| (p[0].as_f64().unwrap(), p[1].as_f64().unwrap())).collect();
|
||
let span = |k: fn(&(f64, f64)) -> f64| corners.iter().map(k).fold(f64::MIN, f64::max) - corners.iter().map(k).fold(f64::MAX, f64::min);
|
||
assert!(same_size((span(|p| p.0), span(|p| p.1)), (20.0, 10.0), 1e-9), "{f}");
|
||
// The DXF: the plate's outline and the engraving's, four lines each, on their layers.
|
||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||
assert_eq!((lines_on(&dxf, "OUTLINE"), lines_on(&dxf, layer)), (4, 4), "{pick:?}: {dxf}");
|
||
// Nothing is stored for it: reopened, the design builds the same sheet.
|
||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||
assert!((volume(&mut reopened) - 2380.0).abs() < 1e-6);
|
||
assert_eq!(flat(&mut reopened), f);
|
||
}
|
||
}
|
||
|
||
/// A plate large enough that its tessellation tolerance (1/1000 of its size, 0.72 mm here) is
|
||
/// more than its engraving's depth keeps the engraving too.
|
||
#[test]
|
||
fn a_large_plate_keeps_an_engraving_shallower_than_its_tessellation_tolerance() {
|
||
let mut s = Session::default();
|
||
run(&mut s, "solid.box", json!({"length": 600, "width": 400, "height": 1, "body_name": "Plate"}));
|
||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 1, "name": "Label"}));
|
||
label_rectangle(&mut s);
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Label", "distance": 0.1, "direction": "negative", "operation": "cut"}));
|
||
for pick in [[450.0, 300.0, 0.0], [450.0, 300.0, 1.0]] {
|
||
let mut s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": pick}));
|
||
// 600·400·1 less the engraving's 20·10·0.1.
|
||
assert!((volume(&mut s) - 239_980.0).abs() < 1e-3, "{pick:?}: {}", volume(&mut s));
|
||
assert_eq!(flat(&mut s)["pockets"].as_array().map(Vec::len), Some(1), "{pick:?}");
|
||
}
|
||
}
|
||
|
||
/// An engraved ring (an "O": a 5 mm circle round a 3 mm island) is one pocket with a hole; the
|
||
/// island stays at full thickness.
|
||
#[test]
|
||
fn an_engraved_ring_converts_as_a_pocket_round_its_island() {
|
||
let mut s = Session::default();
|
||
engraved_plate(
|
||
&mut s,
|
||
0.1,
|
||
|s| {
|
||
run(s, "sketch.circle.center", json!({"center": [20, 20], "radius": 5}));
|
||
run(s, "sketch.circle.center", json!({"center": [20, 20], "radius": 3}));
|
||
},
|
||
&[[24.0, 20.0]],
|
||
);
|
||
// The extrude took the ring between the circles, not the island.
|
||
assert!((volume(&mut s) - (2400.0 - 0.1 * PI * (25.0 - 9.0))).abs() < 1e-3, "{}", volume(&mut s));
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [45, 30, 1]}));
|
||
// The ring's circles are followed within 0.01 mm (1/100 of the thickness): its area within
|
||
// 2/3 of that times the circumferences.
|
||
let want = 2400.0 - 0.1 * PI * (25.0 - 9.0);
|
||
assert!((volume(&mut s) - want).abs() < 0.1 * 0.01 * 2.0 / 3.0 * 2.0 * PI * 8.0, "{} vs {want}", volume(&mut s));
|
||
let (_, hi) = bbox(&mut s);
|
||
assert!(!contains(&s, [24.0, 20.0, hi[2] - 0.05]) && contains(&s, [20.0, 20.0, hi[2] - 0.05]));
|
||
let f = flat(&mut s);
|
||
assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1), "{f}");
|
||
assert_eq!(f["pockets"][0]["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
|
||
}
|
||
|
||
/// What a plate's recesses can't be is refused with the reason, and leaves the design as it was:
|
||
/// a recess deeper than half the plate, recesses in both faces, a plate raised in places (its
|
||
/// recesses larger than the face they are in); a plate with a boss is no plate, as before. A bend
|
||
/// across a kept engraving is refused.
|
||
#[test]
|
||
fn recesses_a_plate_cannot_keep_are_refused_with_the_reason() {
|
||
let err = |s: &mut Session, face: [f64; 3]| {
|
||
s.execute("sheet.convert", &json!({"body": "Plate", "face": face})).err().map(|e| e.to_string()).unwrap_or_default()
|
||
};
|
||
let mut s = Session::default();
|
||
engraved_plate(&mut s, 0.6, label_rectangle, &[]);
|
||
let e = err(&mut s, [45.0, 30.0, 1.0]);
|
||
assert!(e.contains("0.600 mm deep, more than half the plate's 1.000 mm"), "{e}");
|
||
let mut s = Session::default();
|
||
engraved_plate(&mut s, 0.1, label_rectangle, &[]);
|
||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Under"}));
|
||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [50, 30]}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Under", "distance": 0.1, "operation": "cut"}));
|
||
let e = err(&mut s, [45.0, 10.0, 1.0]);
|
||
assert!(e.contains("recesses in both faces"), "{e}");
|
||
// A recess 0.3 mm deep leaving a 2 mm rim: rather a plate 0.3 mm thinner with a raised rim.
|
||
let mut s = Session::default();
|
||
engraved_plate(
|
||
&mut s,
|
||
0.3,
|
||
|s| {
|
||
run(s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [58, 38]}));
|
||
},
|
||
&[],
|
||
);
|
||
let e = err(&mut s, [1.0, 1.0, 1.0]);
|
||
assert!(e.contains("larger than the face they are in"), "{e}");
|
||
// A 0.3 mm boss on the plate: the plate's top round it lies in no opening of the boss's top.
|
||
let mut s = Session::default();
|
||
plate(&mut s);
|
||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 1, "name": "Boss"}));
|
||
label_rectangle(&mut s);
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": 0.3, "operation": "join"}));
|
||
let e = err(&mut s, [20.0, 15.0, 1.3]);
|
||
assert!(e.contains("not a plate of even thickness"), "{e}");
|
||
assert!(s.model.state().sheets.is_empty());
|
||
// A fold across a kept engraving; one clear of it bends.
|
||
let mut s = Session::default();
|
||
engraved_plate(&mut s, 0.1, label_rectangle, &[]);
|
||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [45, 30, 1]}));
|
||
let (_, hi) = bbox(&mut s);
|
||
let fold = |x: f64| json!({"points": [[x, 0, hi[2]], [x, 40, hi[2]]], "angle": 90});
|
||
let e = s.execute("sheet.fold", &fold(20.0)).err().map(|e| e.to_string()).unwrap_or_default();
|
||
assert!(e.contains("crosses a bend zone"), "{e}");
|
||
run(&mut s, "sheet.fold", fold(45.0));
|
||
}
|
||
|
||
/// A bent part converted without bend edges is no plate, as before, whichever face is picked:
|
||
/// the faces of its legs inside its extent are no recesses. An L of two 2 mm plates at 90° (its
|
||
/// legs' faces lie beside its other faces, in no opening of them), a trapezoid section with
|
||
/// returns (faces of its legs on both sides of its base) and a tapered tray (its floor lies in
|
||
/// the opening of its rim, but filled it is no plate: its walls lean out).
|
||
#[test]
|
||
fn a_bent_part_without_bend_edges_is_still_no_plate() {
|
||
let err = |s: &mut Session, body: &str, face: [f64; 3]| {
|
||
s.execute("sheet.convert", &json!({"body": body, "face": face})).err().map(|e| e.to_string()).unwrap_or_default()
|
||
};
|
||
let no_plate = "not supported yet: converting bodies that are not a plate of even thickness without bend edges";
|
||
let mut s = Session::default();
|
||
section(&mut s, &[(0.0, 30.0), (0.0, 0.0), (50.0, 0.0)], 2.0, 40.0);
|
||
for pick in [[25.0, 0.0, 20.0], [25.0, 2.0, 20.0], [1.0, 30.0, 20.0], [0.0, 15.0, 20.0], [2.0, 15.0, 20.0], [50.0, 1.0, 20.0]] {
|
||
let e = err(&mut s, "Section", pick);
|
||
assert!(e.contains(no_plate), "{pick:?}: {e}");
|
||
}
|
||
let mut s = Session::default();
|
||
trapezoid_section(&mut s, 2.0);
|
||
for pick in [[0.0, 0.0, 150.0], [0.0, 2.0, 150.0]] {
|
||
let e = err(&mut s, "Section", pick);
|
||
assert!(e.contains(no_plate), "{pick:?}: {e}");
|
||
}
|
||
let mut s = Session::default();
|
||
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!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"}));
|
||
run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": 2}));
|
||
let e = err(&mut s, "Tray", [100.0, 60.0, 0.0]);
|
||
assert!(e.contains(no_plate), "{e}");
|
||
assert!(s.model.state().sheets.is_empty());
|
||
}
|
||
|
||
/// The flat pattern's cut-outs that are no whole circle (slots), each by the middle of its arcs'
|
||
/// centres.
|
||
fn flat_slots(s: &mut Session) -> Vec<[f64; 2]> {
|
||
let shapes = flat(s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
|
||
let centre = |segs: &Vec<Value>| {
|
||
let c: Vec<[f64; 2]> = segs.iter().filter_map(|g| Some([g["arc"]["center"][0].as_f64()?, g["arc"]["center"][1].as_f64()?])).collect();
|
||
let n = c.len().max(1) as f64;
|
||
[c.iter().map(|p| p[0]).sum::<f64>() / n, c.iter().map(|p| p[1]).sum::<f64>() / n]
|
||
};
|
||
shapes.iter().filter_map(|x| x["segments"].as_array()).map(centre).collect()
|
||
}
|
||
|
||
/// A U channel 40 long (along z) and `t` thick: a web 100 wide on y = 0 and two legs 30 high,
|
||
/// outside lengths to the sharp corners. Returns a point on each outside corner (the bends).
|
||
fn channel(s: &mut Session, t: f64) -> Vec<[f64; 3]> {
|
||
section(s, &[(0.0, 30.0), (0.0, 0.0), (100.0, 0.0), (100.0, 30.0)], t, 40.0)
|
||
}
|
||
|
||
/// The channel folded with two 90° bends, less `cut` mm² of its faces: the web and the legs
|
||
/// between their setbacks R + T, and two quarter rounds on the neutral radius R + T/2.
|
||
fn channel_volume(t: f64, r: f64, cut: f64) -> f64 {
|
||
t * (40.0 * ((100.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0)) - cut)
|
||
}
|
||
|
||
/// Holes and a slot in the faces of a channel converted by its bend edges are cut-outs of the
|
||
/// flat pattern, where the bend allowance puts them: in the fixed face (the web) and in the bent
|
||
/// ones (the legs). With bend edges, such faces were refused ("faces with curved edges").
|
||
#[test]
|
||
fn holes_and_a_slot_in_a_channels_faces_land_where_the_bends_put_them() {
|
||
let (t, k, r) = (2.0, 0.4, 2.0);
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
let bends = channel(&mut s, t);
|
||
// Two 6 mm holes in the web, 30 from either outside corner; one in the right leg and a slot
|
||
// 6 wide and 20 long in the left one, each centred 18 up the leg's outside.
|
||
run(&mut s, "solid.hole", json!({"position": [30, 0, 20], "diameter": 6}));
|
||
run(&mut s, "solid.hole", json!({"position": [70, 0, 20], "diameter": 6}));
|
||
run(&mut s, "solid.hole", json!({"position": [100, 18, 20], "diameter": 6, "depth": 3}));
|
||
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Slot"}));
|
||
run(&mut s, "sketch.slot.overall", json!({"p0": [18, 10], "p1": [18, 30], "width": 6}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "distance": 3, "operation": "cut"}));
|
||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [50, 0, 5], "bends": bends}));
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 40.0).abs() < 1e-9), "{rows:?}");
|
||
// T 2, R 2, K 0.4: BA = π/2 (R + K T) = 1.4 π, BD = 2 (R + T) - BA = 8 - 1.4 π = 3.6018; the
|
||
// flat is the outside lengths less a BD per bend.
|
||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||
assert!((bd - (8.0 - 1.4 * PI)).abs() < 1e-12);
|
||
let across = 160.0 - 2.0 * bd;
|
||
assert!(same_size(flat_size(&mut s), (across, 40.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
// On one line across the flat: the right leg's hole, the web's two, 40 apart, and the slot.
|
||
// From a leg's to the web's nearer one: 18 + 30 along the outside to the sharp, less the BD.
|
||
let (circles, slots) = (flat_circles(&mut s), flat_slots(&mut s));
|
||
assert!(circles.len() == 3 && circles.iter().all(|(radius, _)| (radius - 3.0).abs() < 1e-9) && slots.len() == 1, "{circles:?} {slots:?}");
|
||
let mut centres: Vec<[f64; 2]> = circles.iter().map(|c| c.1).chain(slots).collect();
|
||
centres.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||
let gaps: Vec<f64> = centres.windows(2).map(|w| w[1][0] - w[0][0]).collect();
|
||
assert!(centres.iter().all(|c| (c[1] - centres[0][1]).abs() < 1e-6), "{centres:?}");
|
||
assert!(gaps.len() == 3 && gaps.iter().zip([48.0 - bd, 40.0, 48.0 - bd]).all(|(g, want)| (g - want).abs() < 1e-6), "{gaps:?}");
|
||
// Folded, flat and folded again, less three holes of radius 3 and the slot (14 x 6 and its
|
||
// round ends).
|
||
let cut = 3.0 * PI * 9.0 + 14.0 * 6.0 + PI * 9.0;
|
||
let v = volume(&mut s);
|
||
let want = channel_volume(t, r, cut);
|
||
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
|
||
run(&mut s, "sheet.unfold", json!({}));
|
||
let want = t * (across * 40.0 - cut);
|
||
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||
// The DXF: a CIRCLE per hole, and the slot's round ends as ARCs.
|
||
let (_, arcs, holes) = dxf_entities(&mut s);
|
||
assert_eq!((arcs, holes), (2, 3));
|
||
}
|
||
|
||
/// Round edges of a bent face stay arcs: the legs' free corners rounded by 5, and a notch of
|
||
/// radius 4 in one leg's free edge. They were refused too.
|
||
#[test]
|
||
fn rounded_corners_and_a_notch_on_a_leg_stay_arcs_in_the_flat() {
|
||
let (t, k, r) = (2.0, 0.4, 2.0);
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
let bends = channel(&mut s, t);
|
||
run(&mut s, "solid.fillet", json!({"edges": [[1, 30, 0], [1, 30, 40], [99, 30, 0], [99, 30, 40]], "radius": 5}));
|
||
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Notch"}));
|
||
run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 4}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "Notch", "distance": 3, "operation": "cut"}));
|
||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [50, 0, 5], "bends": bends}));
|
||
let across = 160.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||
assert!(same_size(flat_size(&mut s), (across, 40.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
// Each corner takes (1 - π/4) 5², the notch half a disc of radius 4.
|
||
let cut = 4.0 * (1.0 - PI / 4.0) * 25.0 + PI * 16.0 / 2.0;
|
||
let v = volume(&mut s);
|
||
let want = channel_volume(t, r, cut);
|
||
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
|
||
run(&mut s, "sheet.unfold", json!({}));
|
||
let want = t * (across * 40.0 - cut);
|
||
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||
// The DXF: a quarter ARC at each corner and a half one at the notch, no cut-outs.
|
||
let (_, arcs, holes) = dxf_entities(&mut s);
|
||
assert_eq!((arcs, holes), (5, 0));
|
||
}
|
||
|
||
/// A hole that reaches into a bend has no one place in the flat, and a pocket deeper than half
|
||
/// the sheet is no engraving: both are refused, by their places. (The web's flat panel starts
|
||
/// R + T = 4 from the sharp; the hole reaches from 2 to 8. The pocket is 1.5 deep in the 2 thick
|
||
/// web.)
|
||
#[test]
|
||
fn a_hole_reaching_into_a_bend_and_a_pocket_are_refused_by_their_places() {
|
||
let convert = |cut: &[(&str, Value)]| {
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", 2.0, 0.4, 2.0, 0.5);
|
||
let bends = channel(&mut s, 2.0);
|
||
for (id, p) in cut {
|
||
run(&mut s, id, p.clone());
|
||
}
|
||
let e = s.execute("sheet.convert", &json!({"body": "Section", "face": [50, 0, 5], "bends": bends})).err();
|
||
assert!(s.model.state().sheets.is_empty());
|
||
e.map(|e| e.to_string()).unwrap_or_default()
|
||
};
|
||
let e = convert(&[("solid.hole", json!({"position": [5, 0, 20], "diameter": 6}))]);
|
||
assert!(e.contains("the hole at (5.000, 0.000, 20.000) reaches into a bend"), "{e}");
|
||
let e = convert(&[
|
||
("sketch.create", json!({"plane": "XZ", "name": "Pocket"})),
|
||
("sketch.rectangle.two_point", json!({"p0": [40, -15], "p1": [60, -25]})),
|
||
("sketch.finish", json!({})),
|
||
("solid.extrude", json!({"sketch": "Pocket", "distance": 1.5, "operation": "cut"})),
|
||
]);
|
||
assert!(e.contains("the pocket at (50.000, 0.000, 20.000) in a face of the sheet ends 1.500 deep, more than half the sheet's 2.000"), "{e}");
|
||
}
|
||
|
||
/// The bounding box (low corner, high corner) of a flat pattern pocket's outline.
|
||
fn pocket_box(p: &Value) -> ([f64; 2], [f64; 2]) {
|
||
let pts: Vec<[f64; 2]> = p["outline"].as_array().into_iter().flatten().filter_map(|q| Some([q[0].as_f64()?, q[1].as_f64()?])).collect();
|
||
let lo = [pts.iter().map(|q| q[0]).fold(f64::MAX, f64::min), pts.iter().map(|q| q[1]).fold(f64::MAX, f64::min)];
|
||
let hi = [pts.iter().map(|q| q[0]).fold(f64::MIN, f64::max), pts.iter().map(|q| q[1]).fold(f64::MIN, f64::max)];
|
||
(lo, hi)
|
||
}
|
||
|
||
/// A bent part as another system models it, its bends with their radius, with what its faces
|
||
/// carry: a channel 80 wide with legs 30 high (outside, to the sharp corners), 2 thick, 60 long,
|
||
/// bent R 2 inside and R 4 outside, with a 6 mm hole 18 up its right leg, a label 20 x 10
|
||
/// engraved 0.5 deep into the outside of its web and one 10 x 20 into the inside of its left leg.
|
||
/// Converted from the outside of the web by its outer cylinders under a rule of the model's
|
||
/// radius, the sheet is the model again: the hole is a cut-out where the bend allowance puts it,
|
||
/// and each label is a pocket on its own skin of the sheet.
|
||
#[test]
|
||
fn a_channel_with_its_bends_modelled_keeps_its_hole_and_engravings_on_either_skin() {
|
||
let (t, k, r) = (2.0, 0.4, 2.0);
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
let corners = bent_section(&mut s, &[(0.0, 30.0), (0.0, 0.0), (80.0, 0.0), (80.0, 30.0)], t, r, 60.0);
|
||
run(&mut s, "solid.hole", json!({"position": [80, 18, 20], "diameter": 6, "depth": 3}));
|
||
// The web's label from its outside (y = 0) in, the leg's from its inside (x = 2) out.
|
||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "WebLabel"}));
|
||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, -35], "p1": [50, -45]}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "WebLabel", "distance": 0.5, "operation": "cut"}));
|
||
run(&mut s, "sketch.create", json!({"plane": "YZ", "offset": 2, "name": "LegLabel"}));
|
||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [12, 10], "p1": [22, 30]}));
|
||
run(&mut s, "sketch.finish", json!({}));
|
||
run(&mut s, "solid.extrude", json!({"sketch": "LegLabel", "distance": 0.5, "direction": "negative", "operation": "cut"}));
|
||
// Two flat legs, the web and two quarter rounds on the neutral radius R + T/2, less the hole
|
||
// and the labels.
|
||
let cut = 9.0 * PI * t + 2.0 * 200.0 * 0.5;
|
||
let solid = volume(&mut s);
|
||
let model = t * 60.0 * (2.0 * (30.0 - (r + t)) + (80.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)) - cut;
|
||
assert!((solid - model).abs() < 2e-3 * model, "{solid} vs {model}");
|
||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [65, 0, 10], "bends": [corners[0].0, corners[1].0]}));
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 60.0).abs() < 1e-6), "{rows:?}");
|
||
// 30 + 80 + 30 less two deductions; the sheet is the solid again.
|
||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||
let across = 140.0 - 2.0 * bd;
|
||
assert!(same_size(flat_size(&mut s), (across, 60.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
let v = volume(&mut s);
|
||
assert!((v - solid).abs() < 1e-3 * solid, "{v} vs {solid}");
|
||
// The labels stay in their skins: the web's in its outside, the leg's in its inside.
|
||
assert!(!contains(&s, [40.0, 0.25, 40.0]) && contains(&s, [40.0, 0.75, 40.0]));
|
||
assert!(!contains(&s, [1.75, 17.0, 20.0]) && contains(&s, [1.25, 17.0, 20.0]));
|
||
// The flat: the hole, and a pocket per label, 0.5 deep from the web's skin (z 0) and from the
|
||
// other one (z T). Along the outside from the web label's middle (40 in, 40 along) to the
|
||
// hole's: 40 to the sharp and 18 up the leg, less a deduction, and 20 back along; to the leg
|
||
// label's middle, 40 and 17 the other way.
|
||
let circles = flat_circles(&mut s);
|
||
assert!(circles.len() == 1 && (circles[0].0 - 3.0).abs() < 1e-9, "{circles:?}");
|
||
let f = flat(&mut s);
|
||
let pockets = f["pockets"].as_array().cloned().unwrap_or_default();
|
||
assert_eq!(pockets.len(), 2, "{f}");
|
||
let from = |p: &Value, z0: f64, z1: f64| {
|
||
(p["z0_mm"].as_f64().unwrap_or(f64::NAN) - z0).abs() < 1e-9 && (p["z1_mm"].as_f64().unwrap_or(f64::NAN) - z1).abs() < 1e-9
|
||
};
|
||
let web = pockets.iter().find(|p| from(p, 0.0, 0.5)).expect("the web's label, from z 0");
|
||
let leg = pockets.iter().find(|p| from(p, 1.5, 2.0)).expect("the leg's label, from z T");
|
||
let middle = |p: &Value| {
|
||
let (lo, hi) = pocket_box(p);
|
||
([(lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0], (hi[0] - lo[0], hi[1] - lo[1]))
|
||
};
|
||
let ((w, w_size), (l, l_size), h) = (middle(web), middle(leg), circles[0].1);
|
||
assert!(same_size(w_size, (20.0, 10.0), 1e-6) && same_size(l_size, (10.0, 20.0), 1e-6), "{w_size:?} {l_size:?}");
|
||
let (to_hole, to_leg) = ([h[0] - w[0], h[1] - w[1]], [l[0] - w[0], l[1] - w[1]]);
|
||
assert!(same_size((to_hole[0].abs(), to_hole[1].abs()), (58.0 - bd, 20.0), 1e-6), "{to_hole:?}");
|
||
assert!(same_size((to_leg[0].abs(), to_leg[1].abs()), (57.0 - bd, 20.0), 1e-6), "{to_leg:?}");
|
||
assert!(to_hole[0] * to_leg[0] + to_hole[1] * to_leg[1] < 0.0, "{to_hole:?} {to_leg:?}");
|
||
// The DXF: the hole a CIRCLE, each label on the pocket layer of its depth.
|
||
assert_eq!(dxf_entities(&mut s).2, 1);
|
||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||
assert_eq!((lines_on(&dxf, "POCKET_BOTTOM_Z0.000000_TO_0.500000_MM"), lines_on(&dxf, "POCKET_TOP_Z1.500000_TO_2.000000_MM")), (4, 4));
|
||
// Unfolded and folded again it keeps them.
|
||
run(&mut s, "sheet.unfold", json!({}));
|
||
let want = t * across * 60.0 - cut;
|
||
assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s));
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||
}
|
||
|
||
/// Chamfer bends beside holes: [`chamfered_channel`] with a 6 mm hole in its web 20 from the
|
||
/// left sharp corner and one 18 up its right leg, the chamfers picked in their middles. The holes
|
||
/// land from the virtual sharps, where the faces were taken out to.
|
||
#[test]
|
||
fn holes_beside_chamfer_bends_land_from_the_virtual_sharps() {
|
||
let (t, k, r) = (2.0, 0.5, 3.0);
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
chamfered_channel(&mut s);
|
||
run(&mut s, "solid.hole", json!({"position": [20, 0, 30], "diameter": 6}));
|
||
run(&mut s, "solid.hole", json!({"position": [40, 18, 70], "diameter": 6, "depth": 3}));
|
||
run(&mut s, "sheet.convert", json!({"body": "Channel", "face": [20, 0, 80], "bends": [[2, 2, 50], [38, 2, 50]]}));
|
||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||
let across = 100.0 - 2.0 * bd;
|
||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||
// From the web's hole: 20 to the right sharp, 18 up the leg, less a deduction; 40 along.
|
||
let circles = flat_circles(&mut s);
|
||
assert!(circles.len() == 2 && circles.iter().all(|(radius, _)| (radius - 3.0).abs() < 1e-9), "{circles:?}");
|
||
let d = [circles[1].1[0] - circles[0].1[0], circles[1].1[1] - circles[0].1[1]];
|
||
assert!(same_size((d[0].abs(), d[1].abs()), (38.0 - bd, 40.0), 1e-6), "{d:?}");
|
||
// Three flat strips and two quarter rounds on the neutral radius, less the holes.
|
||
let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)) - 2.0 * t * 9.0 * PI;
|
||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||
}
|
||
|
||
/// The rule of [`rule`] with its corner relief: `shape` 8 mm across.
|
||
fn relief_rule(s: &mut Session, shape: &str) {
|
||
rule(s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||
run(s, "sheet.manage_rules", json!({"name": "Test", "corner_relief_shape": shape, "corner_relief": "8 mm"}));
|
||
}
|
||
|
||
/// The flat outline's corners, from the low corner of their box.
|
||
fn outline_corners(s: &mut Session) -> Vec<[f64; 2]> {
|
||
let f = flat(s);
|
||
let loops = f["outline"].as_array().cloned().unwrap_or_default();
|
||
let pts: Vec<[f64; 2]> =
|
||
loops.iter().flat_map(|l| l.as_array().cloned().unwrap_or_default()).filter_map(|q| Some([q[0].as_f64()?, q[1].as_f64()?])).collect();
|
||
let lo = [pts.iter().map(|q| q[0]).fold(f64::MAX, f64::min), pts.iter().map(|q| q[1]).fold(f64::MAX, f64::min)];
|
||
pts.iter().map(|q| [q[0] - lo[0], q[1] - lo[1]]).collect()
|
||
}
|
||
|
||
/// Whether the flat outline has a corner at each of `want` (from the low corner of its box), the
|
||
/// flat as it lies or turned in its box: half round, or seen from its other face. (The flat frame
|
||
/// runs along a longest edge of the fixed face, which may be either of two, and from a shell's
|
||
/// inner skin it is seen from the other side.)
|
||
fn outline_has(s: &mut Session, want: &[[f64; 2]]) -> bool {
|
||
let pts = outline_corners(s);
|
||
let hi = [pts.iter().map(|q| q[0]).fold(0.0, f64::max), pts.iter().map(|q| q[1]).fold(0.0, f64::max)];
|
||
let turned = |q: [f64; 2], (fx, fy): (bool, bool)| [if fx { hi[0] - q[0] } else { q[0] }, if fy { hi[1] - q[1] } else { q[1] }];
|
||
[(false, false), (true, true), (true, false), (false, true)].iter().any(|&turn| {
|
||
want.iter().all(|w| {
|
||
pts.iter().any(|q| {
|
||
let p = turned(*q, turn);
|
||
(p[0] - w[0]).abs() < 1e-6 && (p[1] - w[1]).abs() < 1e-6
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
/// The corners of a converted tray are a flanged tray's: closed with the rule's gap and given the
|
||
/// rule's corner relief, in the folded part and the flat pattern. The shelled tray of [`tray`] and
|
||
/// the same tray made from a base and four 30 mm edge flanges, T 2, R 2, K 0.44, gap 0.5, a relief
|
||
/// 8 mm across: the bends meet at the base's corners, R + T = 4 in from the sharps, and are
|
||
/// BA = π/2 (R + K T) = 1.44 π wide in the flat. A notch leaves them there (92 and 52 long); a
|
||
/// round or a square relief takes 4 off each bend at each corner and r²·π/4 or r² (r = 4) of the
|
||
/// base; a tear runs each bend on to its corner and cuts its end from there to where its wall's
|
||
/// closed end starts, g/√2 + T from the sharp: R − g/√2 longer. The conversion used to bend every
|
||
/// corner as a notch, whatever the rule said.
|
||
#[test]
|
||
fn a_converted_trays_corners_are_closed_and_relieved_as_a_flanged_trays_are() {
|
||
let (t, r, k, gap) = (2.0, 2.0, 0.44, 0.5);
|
||
let ba = FRAC_PI_2 * (r + k * t);
|
||
let grow = r - gap / SQRT_2;
|
||
// (bend lengths, flat area taken off the notch's at each corner)
|
||
let cases = [
|
||
("notch", (92.0, 52.0), 0.0),
|
||
("round", (84.0, 44.0), 4.0 * PI + 8.0 * ba),
|
||
("rectangular", (84.0, 44.0), 16.0 + 8.0 * ba),
|
||
("tear", (92.0 + grow, 52.0 + grow), -grow * ba),
|
||
];
|
||
let cut = t + gap / SQRT_2;
|
||
let walls = 26.0 * 2.0 * ((100.0 - 2.0 * cut) + (60.0 - 2.0 * cut));
|
||
let flat_notch = t * (92.0 * 52.0 + ba * 2.0 * (92.0 + 52.0) + walls);
|
||
for (shape, (long, short), taken) in cases {
|
||
let mut s = Session::default();
|
||
relief_rule(&mut s, shape);
|
||
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(), "{shape}: {out}");
|
||
let mut lengths: Vec<f64> = bend_rows(&mut s).iter().map(|row| row.2).collect();
|
||
lengths.sort_by(f64::total_cmp);
|
||
let want = [short, short, long, long];
|
||
assert!(lengths.len() == 4 && lengths.iter().zip(want).all(|(l, w)| (l - w).abs() < 1e-6), "{shape}: {lengths:?}");
|
||
// The same tray made from flanges.
|
||
let mut f = Session::default();
|
||
relief_rule(&mut f, shape);
|
||
run(&mut f, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||
run(&mut f, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||
run(&mut f, "sketch.finish", json!({}));
|
||
run(&mut f, "sheet.flange", json!({"sketch": "Base"}));
|
||
run(&mut f, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2], [0, 30, 2]], "height": 30}));
|
||
let mut flanged: Vec<f64> = bend_rows(&mut f).iter().map(|row| row.2).collect();
|
||
flanged.sort_by(f64::total_cmp);
|
||
assert!(lengths.iter().zip(&flanged).all(|(a, b)| (a - b).abs() < 1e-9), "{shape}: {lengths:?} vs {flanged:?}");
|
||
// The flat patterns are congruent, corner for corner.
|
||
let corners = outline_corners(&mut f);
|
||
assert!(
|
||
outline_corners(&mut s).len() == corners.len() && outline_has(&mut s, &corners),
|
||
"{shape}: {:?} vs {corners:?}",
|
||
outline_corners(&mut s)
|
||
);
|
||
let (v, vf) = (volume(&mut s), volume(&mut f));
|
||
assert!((v - vf).abs() < 1e-6 * vf, "{shape}: {v} vs {vf}");
|
||
run(&mut s, "sheet.unfold", json!({}));
|
||
run(&mut f, "sheet.unfold", json!({}));
|
||
let (flat_v, flat_f) = (volume(&mut s), volume(&mut f));
|
||
assert!((flat_v - flat_f).abs() < 1e-6 * flat_f, "{shape}: {flat_v} vs {flat_f}");
|
||
// The flat takes the relief off the notch's (whose walls end g/√2 + T from the sharps).
|
||
let want = flat_notch - 4.0 * t * taken;
|
||
assert!((flat_v - want).abs() < 0.1, "{shape}: flat {flat_v} vs {want}");
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}");
|
||
// A round relief is an arc of radius 4 about each point where two bends meet: the base's
|
||
// corners R + T in from the sharps, 92 and 52 apart.
|
||
if shape == "round" {
|
||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||
let arcs: Vec<(solidcraft_geom::Vec2, f64)> = solidcraft_io::read_dxf(&dxf)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None })
|
||
.collect();
|
||
assert_eq!(arcs.len(), 4, "{arcs:?}");
|
||
let lo = solidcraft_geom::Vec2::new(
|
||
arcs.iter().map(|(c, _)| c.x).fold(f64::MAX, f64::min),
|
||
arcs.iter().map(|(c, _)| c.y).fold(f64::MAX, f64::min),
|
||
);
|
||
for (x, y) in [(0.0, 0.0), (92.0, 0.0), (92.0, 52.0), (0.0, 52.0)] {
|
||
let at = lo + solidcraft_geom::Vec2::new(x, y);
|
||
assert!(arcs.iter().any(|(c, r)| c.dist(at) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A plate 80 x 50, `plate` thick, with a wall 2 thick and 30 high standing on its edge y = 0
|
||
/// from `x0` to `x1`, joined (with a 2 mm plate, a shell).
|
||
fn bracket(s: &mut Session, x0: f64, x1: f64, plate: f64) {
|
||
run(s, "solid.box", json!({"length": 80, "width": 50, "height": plate, "body_name": "Bracket"}));
|
||
run(s, "solid.box", json!({"length": x1 - x0, "width": 2, "height": 30, "corner": [x0, 0, 0], "operation": "join", "targets": ["Bracket"]}));
|
||
}
|
||
|
||
/// A wall standing on part of an edge (an L-bracket whose flange covers part of the plate's edge,
|
||
/// a tab): the plate's edge face and the wall's outside are one face, the plate's edge running on
|
||
/// past the wall a strip T high. Its bend ends where the wall does, and the rule's bend relief
|
||
/// (2 wide, from 1 past the bend line through the plate's edge) is cut into the plate beside each
|
||
/// end of the bend that has more plate beside it, as for an edge flange. The strip, lower than the
|
||
/// bend's setback R + T = 4, was used up by it and the conversion refused; from the inner skin the
|
||
/// bend ended against the plate without a relief. T 2, R 2, K 0.44: BA = 1.44 π, BD = 8 − BA.
|
||
#[test]
|
||
fn a_bend_ends_where_its_wall_ends_with_the_rules_bend_relief_beside_it() {
|
||
let (t, r, k) = (2.0, 2.0, 0.44);
|
||
let ba = FRAC_PI_2 * (r + k * t);
|
||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||
// Folded and flat: the plate less the bend's setback under the wall and the reliefs (2 x 5
|
||
// each), the wall past the bend (30 − 4 high), and the bend on its neutral radius or as its
|
||
// allowance.
|
||
let parts = |wall: f64, reliefs: f64| {
|
||
let flat = 80.0 * 50.0 - 4.0 * wall - 10.0 * reliefs + 26.0 * wall;
|
||
(t * flat + FRAC_PI_2 * t * (r + t / 2.0) * wall, t * (flat + ba * wall))
|
||
};
|
||
// From the flat's low corner (the wall's far edge, 26 + BA − 4 past the plate's edge).
|
||
let edge = 26.0 + ba - 4.0;
|
||
// The wall in the middle, picked from the outside or the inside: the same sheet, the reliefs
|
||
// x 18 … 20 and 60 … 62, through the plate's edge to 1 past the bend line.
|
||
for (face, bend, way) in [([40.0, 25.0, 0.0], [40.0, 0.0, 0.0], "up"), ([40.0, 25.0, 2.0], [40.0, 2.0, 2.0], "down")] {
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
bracket(&mut s, 20.0, 60.0, 2.0);
|
||
let out = run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": face, "bends": [bend]}));
|
||
assert!(out["warning"].is_null(), "{way}: {out}");
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == way && (rows[0].2 - 40.0).abs() < 1e-6, "{way}: {rows:?}");
|
||
assert!(same_size(flat_size(&mut s), (80.0, 80.0 - bd), 1e-6), "{way}: {:?}", flat_size(&mut s));
|
||
let reliefs = [[18.0, edge], [18.0, edge + 5.0], [20.0, edge + 5.0], [60.0, edge + 5.0], [62.0, edge + 5.0], [62.0, edge]];
|
||
assert!(outline_has(&mut s, &reliefs), "{way}: {:?}", outline_corners(&mut s));
|
||
let (folded, flat) = parts(40.0, 2.0);
|
||
let v = volume(&mut s);
|
||
assert!((v - folded).abs() < 1e-4 * folded, "{way}: {v} vs {folded}");
|
||
run(&mut s, "sheet.unfold", json!({}));
|
||
assert!((volume(&mut s) - flat).abs() < 1e-6 * flat, "{way}: {} vs {flat}", volume(&mut s));
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{way}");
|
||
}
|
||
// A wall at the plate's end: its bend runs from the plate's corner, one relief at its end.
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
bracket(&mut s, 0.0, 40.0, 2.0);
|
||
run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": [40, 25, 0], "bends": [[20, 0, 0]]}));
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 1 && (rows[0].2 - 40.0).abs() < 1e-6, "{rows:?}");
|
||
assert!(outline_has(&mut s, &[[0.0, 0.0], [40.0, 0.0], [40.0, edge + 5.0], [42.0, edge + 5.0], [42.0, edge]]), "{:?}", outline_corners(&mut s));
|
||
let (folded, _) = parts(40.0, 1.0);
|
||
assert!((volume(&mut s) - folded).abs() < 1e-4 * folded, "{} vs {folded}", volume(&mut s));
|
||
// A strip taller than the setback (a 5 mm plate, the rule's T 2 taken into the block) stays a
|
||
// low wall of the sheet beside the wall, as before: one bend the plate's length, no relief.
|
||
let mut s = Session::default();
|
||
rule(&mut s, "Test", t, k, r, 0.5);
|
||
bracket(&mut s, 20.0, 60.0, 5.0);
|
||
run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": [40, 25, 0], "bends": [[40, 0, 0]]}));
|
||
let rows = bend_rows(&mut s);
|
||
assert!(rows.len() == 1 && (rows[0].2 - 80.0).abs() < 1e-6, "{rows:?}");
|
||
let low = t * (80.0 * 46.0 + 80.0 * 1.0 + 40.0 * 25.0) + FRAC_PI_2 * t * (r + t / 2.0) * 80.0;
|
||
assert!((volume(&mut s) - low).abs() < 1e-4 * low, "{} vs {low}", volume(&mut s));
|
||
}
|
||
|
||
/// Both together: a plate 100 x 60 x 2 with a wall along its whole edge y = 0 and one on its edge
|
||
/// x = 100 from y = 0 to 30 only, a round corner relief 8 across. The walls meet at the corner,
|
||
/// closed with the gap (each ends g/√2 + T from the other's outside) and relieved there; the
|
||
/// shorter one's bend ends at y = 30 with a bend relief beside it. In the flat from the plate's
|
||
/// corner: the plate from y = 4 and up to x = 96 beside the short wall, 100 past it; bends 96 − 4
|
||
/// and 26 − 4 long; the bend relief x 95 … 100, y 30 … 32.
|
||
#[test]
|
||
fn a_wall_on_part_of_an_edge_meets_a_wall_at_a_relieved_corner_and_is_relieved_at_its_end() {
|
||
let (t, r, k, gap) = (2.0, 2.0, 0.44, 0.5);
|
||
let ba = FRAC_PI_2 * (r + k * t);
|
||
let mut s = Session::default();
|
||
relief_rule(&mut s, "round");
|
||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Part"}));
|
||
run(&mut s, "solid.box", json!({"length": 100, "width": 2, "height": 30, "operation": "join", "targets": ["Part"]}));
|
||
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 30, "corner": [98, 0, 0], "operation": "join", "targets": ["Part"]}));
|
||
let out = run(&mut s, "sheet.convert", json!({"body": "Part", "face": [50, 30, 0], "bends": [[50, 0, 0], [100, 15, 0]]}));
|
||
assert!(out["warning"].is_null(), "{out}");
|
||
let mut lengths: Vec<f64> = bend_rows(&mut s).iter().map(|row| row.2).collect();
|
||
lengths.sort_by(f64::total_cmp);
|
||
assert!(lengths.len() == 2 && (lengths[0] - 22.0).abs() < 1e-6 && (lengths[1] - 92.0).abs() < 1e-6, "{lengths:?}");
|
||
// The plate less the setbacks, the round relief and the bend relief; the walls 26 high, each
|
||
// from the corner's closed end to its own end.
|
||
let cut = t + gap / SQRT_2;
|
||
let base = 100.0 * 56.0 - 4.0 * 26.0 - 4.0 * PI - 10.0;
|
||
let walls = 26.0 * ((100.0 - cut) + (30.0 - cut));
|
||
let folded = t * (base + walls) + FRAC_PI_2 * t * (r + t / 2.0) * 114.0;
|
||
let v = volume(&mut s);
|
||
assert!((v - folded).abs() < 1e-4 * folded, "{v} vs {folded}");
|
||
// From the flat's low corner (the long wall's far edge, 26 + BA − 4 past y = 0): the bend
|
||
// relief's corners.
|
||
let edge = 26.0 + ba - 4.0;
|
||
assert!(outline_has(&mut s, &[[95.0, edge + 30.0], [95.0, edge + 32.0], [100.0, edge + 32.0]]), "{:?}", outline_corners(&mut s));
|
||
run(&mut s, "sheet.unfold", json!({}));
|
||
let flat = t * (base + walls + ba * 114.0);
|
||
assert!((volume(&mut s) - flat).abs() < 0.1, "{} vs {flat}", volume(&mut s));
|
||
run(&mut s, "sheet.refold", json!({}));
|
||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||
}
|