//! 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 = 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::>()); 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::(&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 { 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 { 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, 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 = 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], t: f64) -> String { let mut s = Step::default(); let bottom: Vec> = loops.iter().map(|l| l.iter().map(|w| s.edge(*w, 0.0)).collect()).collect(); let top: Vec> = 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 = 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], 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 = (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 = 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 { 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 = 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::(); assert!((volume(&mut s) - want).abs() < 1e-5 * want, "{l}: {} vs {want}", volume(&mut s)); let mut radii: Vec = 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| { 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::() / n, c.iter().map(|p| p[1]).sum::() / 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 = 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)); }