use std::f64::consts::{FRAC_PI_2, PI}; 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 rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-12) } fn flat(s: &mut Session) -> Value { run(s, "sheet.flat_pattern", json!({})) } /// A w x h base flange on XY. fn base(s: &mut Session, w: f64, h: f64) { run(s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]})); run(s, "sketch.finish", json!({})); run(s, "sheet.flange", json!({"sketch": "Base"})); } fn rectangle_tool(s: &mut Session, name: &str, p0: [f64; 2], p1: [f64; 2]) { run(s, "sketch.create", json!({"plane":"XY","name":name})); run(s, "sketch.rectangle.two_point", json!({"p0":p0,"p1":p1})); run(s, "sketch.finish", json!({})); } #[test] fn shallow_sheet_pocket_preserves_material_depth_export_undo_and_reopen() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); assert!((volume(&mut s) - 14900.0).abs() < 1e-6); let f = flat(&mut s); assert_eq!(f["cutouts"], json!([])); assert_eq!(f["pockets"][0]["side"], "bottom"); assert_eq!(f["pockets"][0]["depth_mm"], 1.0); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert!(dxf.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM")); assert_eq!(solidcraft_io::read_dxf(&dxf).unwrap().len(), 8); run(&mut s, "edit.undo", json!({})); assert!((volume(&mut s) - 15000.0).abs() < 1e-6); run(&mut s, "edit.redo", json!({})); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({})); assert!((volume(&mut s) - 14900.0).abs() < 1e-6); let state = s.model.state(); let body = &state.bodies[0].body; assert!(!body.contains(solidcraft_geom::Vec3::new(25.0, 25.0, 0.5))); assert!(body.contains(solidcraft_geom::Vec3::new(25.0, 25.0, 1.5))); } let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!((volume(&mut reopened) - 14900.0).abs() < 1e-6); assert_eq!(flat(&mut reopened)["pockets"], f["pockets"]); } #[test] fn planar_sheet_intersection_retains_exact_outline_and_islands() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Retain", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Retain","through_all":true,"direction":"symmetric","operation":"intersect"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!((volume(&mut s) - 250.0).abs() < 1e-6); assert_eq!(flat(&mut s)["flat_size_mm"], json!([10.0, 10.0, 2.5])); } let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert_eq!(solidcraft_io::read_dxf(&dxf).unwrap().len(), 4); let mut imported = Session::default(); run(&mut imported, "sketch.insert_dxf", json!({"name":"Retained","text":dxf})); run(&mut imported, "sketch.finish", json!({})); run(&mut imported, "solid.extrude", json!({"sketch":"Retained","distance":2.5})); assert!((volume(&mut imported) - 250.0).abs() < 1e-6); // An annular retained region keeps its central hole, with no first-solid shortcut. let mut annulus = Session::default(); base(&mut annulus, 100.0, 60.0); run(&mut annulus, "sketch.create", json!({"plane":"XY","name":"Ring"})); run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":10})); run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":5})); run(&mut annulus, "sketch.finish", json!({})); run( &mut annulus, "solid.extrude", json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"intersect"}), ); run(&mut annulus, "sheet.unfold", json!({})); let area: f64 = annulus.model.state().sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); assert!((area - PI * 75.0).abs() < 1e-8, "exact retained area: {area}"); // inspect.measure integrates a tessellation; exact arc area is checked above. let measured = volume(&mut annulus); assert!((measured - PI * 75.0 * 2.5).abs() < 0.1, "measured={measured}"); assert_eq!(flat(&mut annulus)["cutouts"].as_array().unwrap().len(), 1); } #[test] fn overlapping_opposite_side_pockets_become_true_through_material() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Bottom", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":1.5,"operation":"cut"})); rectangle_tool(&mut s, "Top", [25.0, 20.0], [35.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Top","start_offset":2.5,"direction":"negative","distance":1.5,"operation":"cut"})); let expected = 15000.0 - 100.0 * 1.5 * 2.0 + 50.0 * 0.5; for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!((volume(&mut s) - expected).abs() < 1e-6); assert_eq!(flat(&mut s)["cutouts"].as_array().unwrap().len(), 1); assert_eq!(flat(&mut s)["pockets"].as_array().unwrap().len(), 2); } } #[test] fn pockets_survive_forming_away_from_bends_and_refuse_bend_overlap_atomically() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20})); assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2); run(&mut s, "sheet.unfold", json!({})); let unfolded = volume(&mut s); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2); assert!(unfolded > 14900.0); let mut cross = Session::default(); base(&mut cross, 100.0, 60.0); rectangle_tool(&mut cross, "Pocket", [45.0, 20.0], [55.0, 30.0]); run(&mut cross, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); let before = cross.doc.to_json(); let undo = cross.undo.len(); let error = cross.execute("sheet.fold", &json!({"points":[[50,0,2.5],[50,60,2.5]]})).unwrap_err().to_string(); assert!(error.contains("pocket") && error.contains("bend"), "{error}"); assert_eq!(cross.doc.to_json(), before); assert_eq!(cross.undo.len(), undo); assert!((volume(&mut cross) - 14900.0).abs() < 1e-6); } #[test] fn unsupported_sheet_machining_is_atomic_but_true_no_change_is_allowed() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Taper", [20.0, 20.0], [30.0, 30.0]); let before = s.doc.to_json(); let undo = s.undo.len(); let error = s.execute("solid.extrude", &json!({"sketch":"Taper","distance":1,"operation":"cut","taper":"5 deg"})).unwrap_err().to_string(); assert!(error.contains("sheet") && error.contains("preserv"), "{error}"); assert_eq!(s.doc.to_json(), before); assert_eq!(s.undo.len(), undo); rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Outside","distance":1,"operation":"cut","taper":"5 deg"})); assert!((volume(&mut s) - 15000.0).abs() < 1e-6); rectangle_tool(&mut s, "All", [-20.0, -20.0], [120.0, 80.0]); run(&mut s, "solid.extrude", json!({"sketch":"All","through_all":true,"direction":"symmetric","operation":"intersect"})); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - 15000.0).abs() < 1e-6); } #[test] fn empty_sheet_intersection_removes_geometry_and_metadata_together() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"})); assert!(s.model.state().bodies.is_empty()); assert!(s.model.state().sheets.is_empty()); assert!(s.execute("sheet.unfold", &json!({})).is_err()); run(&mut s, "edit.undo", json!({})); assert!((volume(&mut s) - 15000.0).abs() < 1e-6); } #[test] fn intersect_preserves_both_material_regions_of_a_logical_ring_sheet() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sketch.create", json!({"plane":"XY","name":"Ring"})); for radius in [10.0, 5.0] { run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":radius})); } run(&mut s, "sketch.finish", json!({})); run( &mut s, "solid.extrude", json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"cut"}), ); rectangle_tool(&mut s, "Crop", [35.0, 15.0], [65.0, 45.0]); run(&mut s, "solid.extrude", json!({"sketch":"Crop","through_all":true,"direction":"symmetric","operation":"intersect"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } let state = s.model.state(); let regions = state.sheets[0].flat_regions().unwrap(); assert_eq!(regions.len(), 2); let area: f64 = regions.iter().map(|r| r.area()).sum(); assert!((area - (900.0 - PI * 75.0)).abs() < 1e-8, "area={area}"); let body = &state.bodies[0].body; assert!(body.contains(solidcraft_geom::Vec3::new(50.0, 30.0, 1.0))); assert!(!body.contains(solidcraft_geom::Vec3::new(57.0, 30.0, 1.0))); assert!(body.contains(solidcraft_geom::Vec3::new(64.0, 30.0, 1.0))); assert!(!body.contains(solidcraft_geom::Vec3::new(70.0, 30.0, 1.0))); assert!((volume(&mut s) - (900.0 - PI * 75.0) * 2.5).abs() < 0.2); } let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert_eq!(solidcraft_io::read_dxf(&dxf).unwrap().len(), 6); } #[test] fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() { use solidcraft_doc::{apply_point, apply_vector}; use solidcraft_geom::Vec3; let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20})); let sh = s.model.state().sheets[0].clone(); let points = sh.panel(&sh.flanges[0]).outer.polyline(1e-3); let c = points.iter().copied().fold(solidcraft_geom::Vec2::ZERO, |sum, p| sum + p) / (points.len() as f64); let w = sh.world(Some(0)); let origin = apply_point(&w, Vec3::ZERO); let x = apply_vector(&w, Vec3::X); let y = apply_vector(&w, Vec3::Y); run( &mut s, "sketch.create", json!({"name":"PanelPocket","plane":{"origin":[origin.x,origin.y,origin.z],"x_dir":[x.x,x.y,x.z],"y_dir":[y.x,y.y,y.z]}}), ); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[c.x-2.0,c.y-2.0],"p1":[c.x+2.0,c.y+2.0]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch":"PanelPocket","distance":1,"operation":"cut"})); assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2); run(&mut s, "sheet.unfold", json!({})); let state = s.model.state(); let body = &state.bodies[0].body; assert!(!body.contains(Vec3::new(c.x, c.y, 0.5))); assert!(body.contains(Vec3::new(c.x, c.y, 1.5))); assert_eq!(flat(&mut s)["pockets"][0]["depth_mm"], 1.0); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2); } #[test] fn flipped_sheet_pocket_keeps_depth_in_the_local_thickness_direction() { let mut s = Session::default(); rectangle_tool(&mut s, "Base", [0.0, 0.0], [100.0, 60.0]); run(&mut s, "sheet.flange", json!({"sketch":"Base","flip":true})); rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"direction":"negative","operation":"cut"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!((volume(&mut s) - 14900.0).abs() < 1e-6); let state = s.model.state(); assert!(!state.bodies[0].body.contains(solidcraft_geom::Vec3::new(25.0, 25.0, -0.5))); assert!(state.bodies[0].body.contains(solidcraft_geom::Vec3::new(25.0, 25.0, -1.5))); } } #[test] fn multiple_profile_intersect_keeps_tool_union_for_solids_and_sheet_groups() { for sheet in [false, true] { let mut s = Session::default(); if sheet { base(&mut s, 100.0, 60.0); } else { run(&mut s, "solid.box", json!({"length":100,"width":60,"height":2.5,"body_name":"Blank"})); } let target = s.model.state().bodies[0].name.clone(); run(&mut s, "solid.box", json!({"corner":[200,0,0],"length":10,"width":10,"height":10,"body_name":"Control"})); run(&mut s, "sketch.create", json!({"plane":"XY","name":"TwoIslands"})); for x in [20.0, 60.0] { run(&mut s, "sketch.rectangle.two_point", json!({"p0":[x,20],"p1":[x+10.0,30]})); } run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch":"TwoIslands","operation":"intersect","distance":3,"targets":[target]})); assert!((volume(&mut s) - 1500.0).abs() < 1e-6); if sheet { for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({"body":target})); assert!((volume(&mut s) - 1500.0).abs() < 1e-6); assert_eq!(flat(&mut s)["outline"].as_array().unwrap().len(), 2); } } let state = s.model.state(); for x in [25.0, 65.0] { assert!(state.bodies.iter().any(|b| b.body.contains(solidcraft_geom::Vec3::new(x, 25.0, 1.0)))); } assert!(!state.bodies.iter().any(|b| b.body.contains(solidcraft_geom::Vec3::new(50.0, 25.0, 1.0)))); assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solidcraft_geom::Vec3::new(205.0, 5.0, 5.0)))); } } #[test] fn deleting_sheet_material_prunes_cosmetic_threads_and_undo_restores_them() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane":"XY","name":"ThreadPlate"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})); run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":5})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch":"ThreadPlate","profiles":[{"point":[10,10]}]})); run(&mut s, "solid.thread", json!({"face":[55,30,1],"designation":"M10"})); assert_eq!(s.model.state().threads.len(), 1); rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"})); assert!(s.model.state().bodies.is_empty()); assert!(s.model.state().sheets.is_empty()); assert!(s.model.state().threads.is_empty()); assert!(solidcraft_doc::world_state(&s.doc, &s.model.state()).threads.is_empty()); run(&mut s, "edit.undo", json!({})); assert_eq!(s.model.state().threads.len(), 1); } #[test] fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() { use solidcraft_geom::Vec3; fn check(s: &Session, name: &str, owner: u64, local_origin: Vec3, local_y: Vec3, transferred: bool) { let state = s.model.state(); let mb = state.body(name).unwrap(); assert_eq!(mb.component, owner); assert_eq!(mb.transferred, transferred); let sh = state.sheets.iter().find(|sh| sh.body == name).unwrap(); assert!(sh.frame.origin.dist(local_origin) < 1e-8, "{:?}", sh.frame); assert!(sh.frame.x.dist(Vec3::X) < 1e-8); assert!(sh.frame.y.dist(local_y) < 1e-8); assert_eq!(sh.pockets.len(), 1); assert_eq!((sh.pockets[0].z0, sh.pockets[0].z1), (0.0, 1.0)); let cut = sh.frame.to_world(solidcraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5; let floor = sh.frame.to_world(solidcraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 1.5; assert!(!mb.body.contains(cut)); assert!(mb.body.contains(floor)); let world = s.world_state(); let body = &world.body(name).unwrap().body; let bounds = body.tessellate(1e-3).unwrap().bounds(); assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "{bounds:?}"); assert!(bounds.max.dist(Vec3::new(100.0, 60.0, 2.5)) < 1e-6, "{bounds:?}"); assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5))); assert!(body.contains(Vec3::new(25.0, 25.0, 1.5))); assert!((solidcraft_kernel::measure(body).unwrap().volume - 14900.0).abs() < 1e-6); } let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); let name = s.model.state().sheets[0].body.clone(); check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0})); let component = destination["component"].as_u64().unwrap(); let occurrence = destination["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); run(&mut s, "component.move_bodies", json!({"component":component,"bodies":[name]})); assert!(!s.doc.body_offsets.contains_key(&name), "recorded transfer must not leave a legacy placement offset"); let origin = Vec3::new(-30.0, -10.0, 20.0); let y = Vec3::new(0.0, 0.0, -1.0); check(&s, &name, component, origin, y, true); run(&mut s, "edit.undo", json!({})); check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); run(&mut s, "edit.redo", json!({})); check(&s, &name, component, origin, y, true); run(&mut s, "component.activate", json!({"component":component})); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({"body":name})); check(&s, &name, component, origin, y, true); let f = run(&mut s, "sheet.flat_pattern", json!({"body":name})); assert_eq!(f["pockets"][0]["side"], "bottom"); assert_eq!(f["pockets"][0]["depth_mm"], 1.0); } let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); check(&reopened, &name, component, origin, y, true); run(&mut reopened, "component.activate", json!({"component":component})); run(&mut reopened, "sheet.unfold", json!({"body":name})); check(&reopened, &name, component, origin, y, true); } #[test] fn base_and_edge_flange_follow_the_k_factor() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); assert!(rel(volume(&mut s), 15000.0) < 1e-9); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); // Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5. let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0; let f = flat(&mut s); assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}"); assert_eq!(f["bends"][0]["direction"], "up"); assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s)); let m = run(&mut s, "inspect.measure", json!({})); assert_eq!(m["total"]["faces"], 14, "{m}"); // The angle is a parameter: 45° (oracle 70 uses height 15). let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default(); run(&mut s, "parameters.change", json!({"name": names[0], "expression": "15"})); run(&mut s, "parameters.change", json!({"name": names[1], "expression": "45 deg"})); assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s)); assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5); // DXF: outline and an up bend line. let d = run(&mut s, "sheet.export_dxf", json!({})); let text = d["dxf"].as_str().unwrap_or_default(); assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}"); assert!(s.execute("sheet.flange", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge"); } #[test] fn rules_are_parameters() { let mut s = Session::default(); run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "1.5 mm"})); run(&mut s, "sheet.manage_rules", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"})); base(&mut s, 80.0, 50.0); run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20})); // Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980. let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5; assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6); assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s)); // Thicker: everything follows. run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "2 mm"})); let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0; assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6); let l = run(&mut s, "sheet.rules", json!({})); assert_eq!(l["active"], "Custom", "{l}"); assert!(s.execute("sheet.manage_rules", &json!({"name": "Bad", "k_factor": 3})).is_err()); } #[test] fn contour_flange_hem_and_tray() { // Oracle 72. let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"})); run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50})); assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s)); assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5); // Oracle 73: flange, then a flat hem of 8. let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); run(&mut s, "sheet.hem", json!({"edges": [[50, 0, 20]], "length": 8})); assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s)); let b = flat(&mut s)["bends"].clone(); assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}"); // A tray: four flanges at once stop at each other's bends. let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25})); let f = flat(&mut s); // Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across. assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}"); let v = volume(&mut s); // Each flange runs on past its bend toward the corner until the inner corners are the // rule's gap apart (diagonally): 5 − (2.5 + 2.5/√2) = 0.732 at each of 8 ends. let section = PI / 4.0 * (25.0 - 6.25) + 2.5 * 20.0; let ext = 5.0 - (2.5 + 2.5 / std::f64::consts::SQRT_2); let want = 90.0 * 50.0 * 2.5 + section * (2.0 * 90.0 + 2.0 * 50.0) + 8.0 * ext * 2.5 * 20.0; assert!(rel(v, want) < 1e-4 && rel(v, 29666.234) < 1e-4, "{v} {want}"); } #[test] fn unfold_cut_refold_and_convert() { // Oracle 74: two flanges, unfold, a slot across the bend, refold. let mut s = Session::default(); base(&mut s, 80.0, 50.0); run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20})); let folded = volume(&mut s); run(&mut s, "sheet.unfold", json!({})); let m = run(&mut s, "inspect.measure", json!({})); assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}"); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [35, -12], "p1": [45, 10]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"})); run(&mut s, "sheet.refold", json!({})); let v = volume(&mut s); assert!(v < folded - 300.0, "{v} {folded}"); assert!(rel(v, 15800.304) < 1e-4, "{v}"); assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); // Convert a plate. let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"})); run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2.5]})); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s)); // A rod picked on its end is a round plate as thick as the rod is long (as a 20 x 20 x 30 bar // is a square one); a block thicker at one end is no plate. let mut t = Session::default(); run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"})); run(&mut t, "sheet.convert", json!({"body": "Rod", "face": [0, 0, 30]})); let f = flat(&mut t); assert!(rel(f["thickness"].as_f64().unwrap_or(0.0), 30.0) < 1e-9 && rel(f["flat_size_mm"][0].as_f64().unwrap_or(0.0), 20.0) < 1e-4, "{f}"); let mut t = Session::default(); run(&mut t, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Block"})); run(&mut t, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 60, "height": 10, "operation": "join"})); assert!(t.execute("sheet.convert", &json!({"body": "Block", "face": [50, 30, 2.5]})).is_err()); } /// A sketch line across a 100 x 60 plate's top face at x = 40. fn fold_line(s: &mut Session) { run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"})); run(s, "sketch.line", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]})); run(s, "sketch.finish", json!({})); } fn bbox(s: &mut Session) -> Value { run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone() } #[test] fn fold_takes_the_bend_out_of_the_flat_sheet() { let (t, r, k) = (2.5, 2.5, 0.44); let ba = FRAC_PI_2 * (r + k * t); let mut s = Session::default(); base(&mut s, 100.0, 60.0); fold_line(&mut s); run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"})); // The flat pattern keeps its size; the bend's centre line is the sketch line. let f = flat(&mut s); assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{f}"); assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 60.0).abs() < 1e-6, "{f}"); assert!((f["bends"][0]["allowance"].as_f64().unwrap_or(0.0) - ba).abs() < 1e-9, "{f}"); // Folded: the zone's flat volume BA·T·w becomes the bend's θ·T·(R + T/2)·w. let want = 15000.0 - ba * t * 60.0 + FRAC_PI_2 * t * (r + t / 2.0) * 60.0; assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s)); // The smaller side (x < 40) stands up: outer face at the zone start less R + T, leg 40 − BA/2 // tall above the bend. let b = bbox(&mut s); assert!((b["min"][0].as_f64().unwrap_or(0.0) - (40.0 + ba / 2.0 - r - t)).abs() < 1e-6, "{b}"); assert!((b["max"][2].as_f64().unwrap_or(0.0) - (t + r + 40.0 - ba / 2.0)).abs() < 1e-6, "{b}"); assert!((b["max"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{b}"); } #[test] fn fold_positions_fixed_side_and_flip() { let (t, r, k) = (2.5, 2.5, 0.44); let ba = FRAC_PI_2 * (r + k * t); let case = |p: Value| { let mut s = Session::default(); base(&mut s, 100.0, 60.0); fold_line(&mut s); let mut q = json!({"sketch": "FoldLine", "curve": "fl"}); for (k, v) in p.as_object().into_iter().flatten() { q[k] = v.clone(); } run(&mut s, "sheet.fold", q); bbox(&mut s) }; let x0 = |b: &Value| b["min"][0].as_f64().unwrap_or(f64::NAN); // Start: the bend starts at the line (on the fixed side), so the folded outer face is R + T // short of it. End: the bend ends there. Mould: the outer face lands on the line. assert!((x0(&case(json!({"position": "start"}))) - (40.0 - r - t)).abs() < 1e-6); assert!((x0(&case(json!({"position": "end"}))) - (40.0 + ba - r - t)).abs() < 1e-6); assert!((x0(&case(json!({"position": "mould"}))) - 40.0).abs() < 1e-6); // Keep the small side: the large one (x > 40) stands up instead. let b = case(json!({"fixed": [10, 30, 2.5]})); assert!((b["max"][0].as_f64().unwrap_or(0.0) - (40.0 - ba / 2.0 + r + t)).abs() < 1e-6, "{b}"); assert!((b["max"][2].as_f64().unwrap_or(0.0) - (t + r + 60.0 - ba / 2.0)).abs() < 1e-6, "{b}"); // Flipped: folds down, below the sheet. let b = case(json!({"flip": true})); assert!((b["min"][2].as_f64().unwrap_or(0.0) + (r + 40.0 - ba / 2.0)).abs() < 1e-6, "{b}"); // A 45° fold: the leg leans. let b = case(json!({"angle": "45 deg"})); assert!(b["max"][2].as_f64().unwrap_or(0.0) < t + r + 40.0 - ba / 2.0); } #[test] fn fold_carries_flanges_on_the_moving_side() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); // A flange on the left edge, then a fold between it and the rest. run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 20})); let v0 = volume(&mut s); fold_line(&mut s); run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]})); // The flange rode along: it is above the plate now, nothing left at z < 0. let b = bbox(&mut s); assert!(b["min"][2].as_f64().unwrap_or(-1.0) > -1e-6, "{b}"); let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); let want = v0 - ba * 2.5 * 60.0 + FRAC_PI_2 * 2.5 * (2.5 + 1.25) * 60.0; assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s)); assert!(flat(&mut s)["bends"].as_array().map(Vec::len) == Some(2)); // Errors: off the sheet, through the flange's bend. let mut e = Session::default(); base(&mut e, 100.0, 60.0); assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err()); assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err()); assert!(e.execute("sheet.fold", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend"); } #[test] fn fold_on_a_flange_panel() { let (t, r, k) = (2.5, 2.5, 0.44); let ba = FRAC_PI_2 * (r + k * t); let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 30})); let flat0 = flat(&mut s)["flat_size_mm"].clone(); let v0 = volume(&mut s); // The flange stands up at the plate's y = 0 edge: its outer face is the body's min y. let y = bbox(&mut s)["min"][1].as_f64().unwrap_or(f64::NAN); run(&mut s, "sheet.fold", json!({"points": [[10, y, 18], [90, y, 18]]})); let f = flat(&mut s); for i in 0..2 { assert!( (f["flat_size_mm"][i].as_f64().unwrap_or(0.0) - flat0[i].as_f64().unwrap_or(1.0)).abs() < 1e-6, "the flat pattern keeps its size: {f}" ); } assert_eq!(f["bends"].as_array().map(Vec::len), Some(2)); let len = f["bends"][1]["length"].as_f64().unwrap_or(0.0); assert!(len > 90.0, "{f}"); let want = v0 - ba * t * len + FRAC_PI_2 * t * (r + t / 2.0) * len; assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s)); // The top part now leans over the plate (folded up, toward its top face). let b = bbox(&mut s); assert!(b["max"][2].as_f64().unwrap_or(99.0) < 25.0, "{b}"); // Folding across the flange's own bend is refused. assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err()); } #[test] fn fold_through_cut_outs() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); // A slot across the fold line (edges along and across the bend). run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 20], "p1": [50, 40]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"})); let mut plain = Session::default(); base(&mut plain, 100.0, 60.0); fold_line(&mut plain); run(&mut plain, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"})); fold_line(&mut s); run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"})); let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}"); let (v, vp) = (volume(&mut s), volume(&mut plain)); // The slot takes 20 x 20 of the flat sheet; across the bend it removes the bend's share. assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}"); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s)); // A round hole across the bend folds too: its edge runs over the bend as a curve on the // bend's cylinder, and unfolds to the same round hole. let mut c = Session::default(); base(&mut c, 100.0, 60.0); run(&mut c, "sketch.create", json!({"plane": "XY", "name": "Hole"})); run(&mut c, "sketch.circle.center", json!({"center": [40, 30], "radius": 5})); run(&mut c, "sketch.finish", json!({})); run(&mut c, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"})); fold_line(&mut c); run(&mut c, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"})); let f = flat(&mut c); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}"); let folded = volume(&mut c); assert!(folded < 15000.0 - 150.0, "{folded}"); run(&mut c, "sheet.unfold", json!({})); let want = 15000.0 - std::f64::consts::PI * 25.0 * 2.5; assert!((volume(&mut c) - want).abs() < 0.05, "{} vs {want}", volume(&mut c)); } #[test] fn flange_edges_are_named_and_follow_the_base_sketch() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Base"})); run(&mut s, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"})); let side = s.doc.find_feature("Side").cloned().unwrap(); assert_eq!(side.edge_names, vec!["base:l2:top".to_string()]); let v0 = volume(&mut s); // Narrower base: the flange stays on the right edge (now at x = 40). let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into()); run(&mut s, "parameters.change", json!({"name": name, "expression": "40"})); let b = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone(); let w = b["max"][0].as_f64().unwrap_or(0.0) - b["min"][0].as_f64().unwrap_or(0.0); assert!((w - 40.0).abs() < 1e-3 && b["max"][2].as_f64().unwrap_or(0.0) > 19.0, "{b}"); assert!(volume(&mut s) < v0); let id = side.id; assert_eq!(s.model.result(id).and_then(|r| r.warning.clone()), None); // A hem on the flange's far edge is named after the flange. let top = b["max"][2].as_f64().unwrap_or(0.0); let x = b["max"][0].as_f64().unwrap_or(0.0); run(&mut s, "sheet.hem", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"})); let h = s.doc.find_feature("H").cloned().unwrap(); assert!(h.edge_names[0].starts_with(&format!("F{id}.0:far")), "{:?}", h.edge_names); } /// With no fixed side, the larger side stays whichever side of the line it is on (QA s22). #[test] fn fold_keeps_the_larger_side_either_way() { for (w, line, moving_right) in [(60.0, 40.0, true), (100.0, 40.0, false)] { let mut s = Session::default(); base(&mut s, w, 40.0); run(&mut s, "sheet.fold", json!({"points": [[line, 0, 2.5], [line, 40, 2.5]]})); let b = bbox(&mut s); let (x0, x1) = (b["min"][0].as_f64().unwrap_or(0.0), b["max"][0].as_f64().unwrap_or(0.0)); if moving_right { assert!(x0.abs() < 1e-6 && x1 < line + 5.0, "the left (larger) part stays: {b}"); } else { assert!((x1 - w).abs() < 1e-6 && x0 > line - 5.0, "the right (larger) part stays: {b}"); } } } /// A 100 x 60 plate with a 90° flange up from its y = 0 edge (steel: 2.5 thick, R = T, K 0.44). /// The flange's inner face is the plane y = 2.5 and its outer face y = 0; it stands from z = 5. fn plate_with_flange(s: &mut Session) { base(s, 100.0, 60.0); run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); } /// A circle sketch on `plane`, finished. fn circle_sketch(s: &mut Session, name: &str, plane: Value, center: [f64; 2], diameter: f64) -> Value { let made = run(s, "sketch.create", json!({"plane": plane, "name": name})); run(s, "sketch.circle.center", json!({"center": center, "diameter": diameter})); run(s, "sketch.finish", json!({})); made } fn cut_through(s: &mut Session, sketch: &str) { run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "negative", "operation": "cut"})); } 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]))) } #[test] fn a_sketch_on_a_flange_face_attaches_to_its_panel() { // Sketch coordinates on a sheet face are the flat pattern's: the flange panel starts at // y = 5 − BA and runs on toward −y, so a hole 7 mm up the face is at y = 5 − BA − 7. let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); let hole = PI * 1.8 * 1.8 * 2.5; let mut s = Session::default(); plate_with_flange(&mut s); let v0 = volume(&mut s); let made = circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, 5.0 - ba - 7.0], 3.6); assert_eq!(made["attachment"], "sheet_face"); // The sketch keeps the face's name: the body, the panel (its flange) and the side of the sheet. let sketch = s.doc.find_feature("Holes").cloned().unwrap(); let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &sketch.kind else { panic!("{:?}", sketch.kind) }; let name = solidcraft_doc::sheet::plane_sheet_face(plane).unwrap_or_default(); assert!(name.starts_with("sheet:Body1:F") && name.ends_with(":top"), "{name}"); // A cut through the flange from that sketch is a round hole 7 mm up the face, at x = 30. run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 2.5, "direction": "negative", "operation": "cut"})); assert!((v0 - volume(&mut s) - hole).abs() < 0.1, "{}", v0 - volume(&mut s)); assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]) && contains(&s, [33.0, 1.25, 12.0])); let f = flat(&mut s); let cut = f["cutouts"][0].as_array().cloned().unwrap_or_default(); assert!(!cut.is_empty(), "{f}"); for p in &cut { let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)); assert!(((x - 30.0).hypot(y - (5.0 - ba - 7.0)) - 1.8).abs() < 1e-3, "({x}, {y})"); } // `fixed: true` still freezes the picked plane instead of attaching to the panel. let frozen = circle_sketch(&mut s, "Frozen", json!({"face": [50, 2.5, 12], "fixed": true}), [0.0, 0.0], 1.0); assert_eq!(frozen["attachment"], "fixed"); } /// The thickness of a sheet comes from a parameter: the flange's face moves with it, and a /// sketch frozen on the old plane cuts a blind pocket in the thicker sheet. The attached /// sketch cuts the same hole as one drawn on the thicker sheet in the first place. #[test] fn a_sketch_on_a_sheet_face_follows_the_sheet_when_its_thickness_changes() { let build = |t: f64, then: Option| { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name": "T", "expression": format!("{t} mm")})); run(&mut s, "sheet.manage_rules", json!({"name": "AL", "thickness": "T", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, t]], "height": 20})); // The hole is drawn 10 mm up the 2 mm flange, in the flat pattern: its bend ends at // y = T + R = 3 and the bend allowance is (R + T/2)·π/2 = π. The coordinates stay as they are. let flat_y = 3.0 - PI - 10.0; circle_sketch(&mut s, "Holes", json!({"face": [50, t, 12]}), [30.0, flat_y], 6.0); run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, t]}, "offset": "5 mm", "name": "Above"})); run(&mut s, "sketch.finish", json!({})); let v0 = volume(&mut s); cut_through(&mut s, "Holes"); if let Some(t2) = then { let r = run(&mut s, "parameters.change", json!({"name": "T", "expression": format!("{t2} mm")})); assert_eq!(r["errors"], json!([]), "{r}"); } (s, v0) }; let (mut grown, _) = build(2.0, Some(3.0)); let (mut direct, v_direct) = build(3.0, None); let (vg, vd) = (volume(&mut grown), volume(&mut direct)); // The hole is the cylinder Ø6 × 3 through the thicker sheet, as if drawn there at first. assert!((v_direct - vd - PI * 9.0 * 3.0).abs() < 0.1, "{}", v_direct - vd); assert!(rel(vg, vd) < 1e-9, "{vg} {vd}"); let (fg, fd) = (flat(&mut grown), flat(&mut direct)); assert_eq!(fg["pockets"], json!([]), "a blind pocket instead of the hole: {fg}"); assert_eq!(fg["cutouts"], fd["cutouts"]); // The thicker flange's panel starts at flat y = 4 − 3.927 and z = T + R = 4, so the hole is // 10.215 mm up it: through the whole 3 mm. assert!(!contains(&grown, [30.0, 1.5, 14.2]) && !contains(&grown, [30.0, 0.2, 14.2]) && contains(&grown, [30.0, 1.5, 19.0])); // The sketch offset from the base's top face moved up with it: 3 + 5. let plane = run(&mut grown, "sketch.inspect", json!({"sketch": "Above"}))["plane"]["origin"][2].as_f64().unwrap_or(f64::NAN); assert!((plane - 8.0).abs() < 1e-9, "{plane}"); } #[test] fn sketches_on_the_top_and_bottom_faces_of_a_sheet_survive_unfold_refold_and_reopen() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); // The top face's coordinates are the base sketch's; the bottom face's mirror y, as seen from below. circle_sketch(&mut s, "Top", json!({"face": [30, 20, 2.5]}), [30.0, 20.0], 4.0); circle_sketch(&mut s, "Bottom", json!({"face": [60, 20, 0]}), [60.0, -20.0], 4.0); cut_through(&mut s, "Top"); cut_through(&mut s, "Bottom"); let want = 15000.0 - 2.0 * PI * 4.0 * 2.5; assert!((volume(&mut s) - want).abs() < 0.1, "{}", volume(&mut s)); let holes = |f: &Value| { let mut centres: Vec<(f64, f64)> = f["cutouts"] .as_array() .cloned() .unwrap_or_default() .iter() .map(|c| { let pts: Vec<(f64, f64)> = c .as_array() .cloned() .unwrap_or_default() .iter() .map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))) .collect(); let n = pts.len().max(1) as f64; (pts.iter().map(|p| p.0).sum::() / n, pts.iter().map(|p| p.1).sum::() / n) }) .collect(); centres.sort_by(|a, b| a.0.total_cmp(&b.0)); centres }; for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } let centres = holes(&flat(&mut s)); assert_eq!(centres.len(), 2, "{centres:?}"); assert!((centres[0].0 - 30.0).abs() < 0.01 && (centres[0].1 - 20.0).abs() < 0.01, "{centres:?}"); assert!((centres[1].0 - 60.0).abs() < 0.01 && (centres[1].1 - 20.0).abs() < 0.01, "{centres:?}"); assert!((volume(&mut s) - want).abs() < 0.1); } let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!((volume(&mut reopened) - want).abs() < 0.1); assert_eq!(holes(&flat(&mut reopened)), holes(&flat(&mut s))); } #[test] fn a_sketch_made_on_the_unfolded_sheet_lands_on_the_flange_after_refolding() { let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); let mut s = Session::default(); plate_with_flange(&mut s); let v0 = volume(&mut s); run(&mut s, "sheet.unfold", json!({})); // Unfolded, the flange lies in the sheet's plane: the one flat face covers it too. let y = 5.0 - ba - 7.0; circle_sketch(&mut s, "Holes", json!({"face": [30, y, 2.5]}), [30.0, y], 3.6); cut_through(&mut s, "Holes"); run(&mut s, "sheet.refold", json!({})); assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s)); assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5])); } #[test] fn a_sketch_on_a_sheet_face_reports_a_lost_attachment_instead_of_moving_to_another_face() { let mut s = Session::default(); plate_with_flange(&mut s); circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6); let id = s.doc.find_feature("Holes").map(|f| f.id).unwrap_or(0); let status = |s: &Session| s.placed_document().sketch_face_attachment(id, &s.model); assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(_) })); // With the flange suppressed there is no such face; the base's top face is not a substitute. run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": true})); let error = run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].as_str().unwrap_or_default().to_string(); assert!(error.contains("lost face attachment") && error.contains("reattach"), "{error}"); let solidcraft_doc::FaceAttachmentStatus::Lost { message, repair_plane } = status(&s) else { panic!("{:?}", status(&s)) }; assert!(message.contains("sheet face") && repair_plane.is_some(), "{message}"); // Redefine Sketch Plane repairs it on another face, here the base's top (its sketch keeps its geometry). let repaired = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [10, 30, 2.5]}})); assert_eq!(repaired["attachment"], "sheet_face"); assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null()); assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. })); // The flange is back, and a sketch attached to it again is healthy. run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": false})); let again = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [50, 2.5, 12]}})); assert_eq!(again["attachment"], "sheet_face"); assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null()); assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. })); } #[test] fn renaming_a_sheet_body_keeps_its_sketches_on_their_faces() { let mut s = Session::default(); plate_with_flange(&mut s); let v0 = volume(&mut s); circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6); run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, 2.5]}, "offset": "5 mm", "name": "Above"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "body.rename", json!({"body": "Body1", "name": "Bracket"})); for (sketch, face) in [("Holes", "sheet:Bracket:F"), ("Above", "sheet:Bracket:base:top")] { let feature = s.doc.find_feature(sketch).cloned().unwrap(); let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &feature.kind else { panic!("{:?}", feature.kind) }; assert!(solidcraft_doc::sheet::plane_sheet_face(plane).is_some_and(|name| name.starts_with(face)), "{sketch}: {:?}", feature.kind); assert!(run(&mut s, "timeline.inspect", json!({"feature": sketch}))["error"].is_null(), "{sketch}"); } cut_through(&mut s, "Holes"); assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s)); } /// A tray shrunk by a parameter change unfolds like the same tray built at that size. The /// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar /// arrangement used to drop a bend zone through a gap that small: "planar region arrangement /// failed", for W alone or both sizes, at 72° or 90°, while growing the tray worked. #[test] fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() { for angle in [72.0, 90.0] { let tray = |s: &mut Session, w: f64, h: f64| { run(s, "parameters.add", json!({"name": "W", "expression": format!("{w} mm")})); run(s, "parameters.add", json!({"name": "H", "expression": format!("{h} mm")})); run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); run(s, "sketch.create", json!({"plane": "XY", "name": "Tray"})); run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]})); run(s, "sketch.constraint.fix", json!({"entities": ["p1"]})); run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"})); run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "H"})); run(s, "sketch.finish", json!({})); run(s, "sheet.flange", json!({"type": "base", "sketch": "Tray"})); let edges = json!([[w / 2.0, 0, 2], [w, h / 2.0, 2], [w / 2.0, h, 2], [0, h / 2.0, 2]]); run(s, "sheet.flange", json!({"type": "edge", "edges": edges, "height": 38.6, "angle": angle})); run(s, "sheet.unfold", json!({})); }; let mut direct = Session::default(); tray(&mut direct, 400.0, 300.0); let want = flat(&mut direct)["flat_size_mm"].clone(); let mut shrunk = Session::default(); tray(&mut shrunk, 2000.0, 2500.0); for (name, expr) in [("W", "400 mm"), ("H", "300 mm")] { let r = run(&mut shrunk, "parameters.change", json!({"name": name, "expression": expr})); assert_eq!(r["errors"], json!([]), "{angle}° {name} = {expr}: {r}"); } let got = flat(&mut shrunk)["flat_size_mm"].clone(); for k in 0..3 { assert!((got[k].as_f64().unwrap() - want[k].as_f64().unwrap()).abs() < 1e-9, "{angle}°: {got} {want}"); } assert!(rel(volume(&mut shrunk), volume(&mut direct)) < 1e-9, "{angle}°"); } } #[test] fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() { // A 200 x 100 plate with a 40 mm flange along y = 0: the bend's centre line runs at y = -pi/2 // (R 1, T 2, K 0.5: a bend allowance of pi) from x = 0 to 200. let mut s = Session::default(); run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm", "active": true})); base(&mut s, 200.0, 100.0); run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"})); // (bend number, length, start x, end x) of every row; all on the centre line. let rows = |s: &mut Session| -> Vec<(u64, f64, f64, f64)> { let f = flat(s); let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN); f["bends"] .as_array() .cloned() .unwrap_or_default() .iter() .map(|b| { assert!((num(&b["start"][1]) + FRAC_PI_2).abs() < 1e-9 && (num(&b["end"][1]) + FRAC_PI_2).abs() < 1e-9, "{b}"); (b["bend"].as_u64().unwrap_or(0), num(&b["length"]), num(&b["start"][0]), num(&b["end"][0])) }) .collect() }; let close = |got: &[(u64, f64, f64, f64)], want: &[(u64, f64, f64, f64)]| { got.len() == want.len() && got.iter().zip(want).all(|(g, w)| g.0 == w.0 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9 && (g.3 - w.3).abs() < 1e-9) }; // The DXF's bend lines: both ends' x of every LINE along the centre line, left to right; the // BEND_UP layer holds nothing else. let bend_lines = |s: &mut Session| -> Vec<(f64, f64)> { let text = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); let on_layer = text.lines().collect::>().windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(); let mut found: Vec<(f64, f64)> = solidcraft_io::read_dxf(&text) .unwrap() .iter() .filter_map(|g| match g { solidcraft_io::Geom2::Line(a, b) if (a.y + FRAC_PI_2).abs() < 1e-6 && (b.y + FRAC_PI_2).abs() < 1e-6 => { Some((a.x.min(b.x), a.x.max(b.x))) } _ => None, }) .collect(); found.sort_by(|a, b| a.0.total_cmp(&b.0)); assert_eq!(found.len(), on_layer, "{text}"); found }; assert!(close(&rows(&mut s), &[(1, 200.0, 0.0, 200.0)]), "{:?}", rows(&mut s)); assert_eq!(bend_lines(&mut s).len(), 1); // A flat cut takes the flange's left half, its bend and a strip of the base. run(&mut s, "sheet.unfold", json!({})); rectangle_tool(&mut s, "Half", [-10.0, -60.0], [100.0, 10.0]); run(&mut s, "solid.extrude", json!({"sketch": "Half", "through_all": true, "direction": "symmetric", "operation": "cut"})); run(&mut s, "sheet.refold", json!({})); assert!(close(&rows(&mut s), &[(1, 100.0, 100.0, 200.0)]), "{:?}", rows(&mut s)); let lines = bend_lines(&mut s); assert!(lines.len() == 1 && (lines[0].0 - 100.0).abs() < 1e-6 && (lines[0].1 - 200.0).abs() < 1e-6, "{lines:?}"); let csv = run(&mut s, "sheet.export_bend_table", json!({})); let text = csv["text"].as_str().unwrap_or_default(); assert_eq!((text.lines().count(), csv["bends"].as_u64()), (2, Some(1)), "{text}"); assert!(text.lines().nth(1).is_some_and(|l| l.contains(",1,90.000000000,up,1.000000000,3.141592654,100.000000000,100.000000000,")), "{text}"); // A slot across what is left splits the bend: a row and a line for each piece, both bend 1. run(&mut s, "sheet.unfold", json!({})); rectangle_tool(&mut s, "Slot", [140.0, -20.0], [160.0, 10.0]); run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); run(&mut s, "sheet.refold", json!({})); assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s)); let lines = bend_lines(&mut s); assert!(lines.len() == 2 && (lines[0].1 - 140.0).abs() < 1e-6 && (lines[1].0 - 160.0).abs() < 1e-6, "{lines:?}"); let csv = run(&mut s, "sheet.export_bend_table", json!({})); assert_eq!((csv["text"].as_str().unwrap_or_default().lines().count(), csv["bends"].as_u64()), (3, Some(2))); // Unfolded, the flat pattern lists the same pieces. run(&mut s, "sheet.unfold", json!({})); assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s)); } /// The flat pattern's area: its outline loops less its cut-outs. fn flat_area(f: &Value) -> f64 { let loops = |k: &str| -> f64 { let all = f[k].as_array().cloned().unwrap_or_default(); all.iter() .map(|l| { let pts: Vec<(f64, f64)> = l .as_array() .cloned() .unwrap_or_default() .iter() .map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN))) .collect(); polygon_area(&pts) }) .sum() }; loops("outline") - loops("cutouts") } #[test] fn an_edge_flange_made_after_a_cut_runs_along_the_edge_the_cut_left() { // A 200 x 100 plate, its bottom left 100 x 60 cut away, then a flange picked on what is left of // the bottom edge (x 100 … 200): it used to span the whole original edge, x 0 … 200, its left // half standing beside nothing. let mut s = Session::default(); run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm", "active": true})); base(&mut s, 200.0, 100.0); rectangle_tool(&mut s, "Corner", [-10.0, -10.0], [100.0, 60.0]); run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"})); assert!((volume(&mut s) - 14_000.0 * 2.0).abs() < 1e-6); run(&mut s, "sheet.flange", json!({"edges": [[150, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"})); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); let f = flat(&mut s); let bends = f["bends"].as_array().cloned().unwrap_or_default(); assert_eq!(bends.len(), 1, "{f}"); let x = |k: &str| bends[0][k][0].as_f64().unwrap_or(f64::NAN); assert!( (x("start") - 100.0).abs() < 1e-9 && (x("end") - 200.0).abs() < 1e-9 && (bends[0]["length"].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-9, "{f}" ); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); // The flat pattern: the plate less the cut, and the flange (π of bend, 37 of panel) on 100 mm. assert!((flat_area(&f) - (14_000.0 + 100.0 * (PI + 37.0))).abs() < 1e-6, "{}", flat_area(&f)); // Folded: the bend keeps its volume (K 0.5); the flange stands on x 100 … 200 only. let want = (14_000.0 + 100.0 * (PI + 37.0)) * 2.0; assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{} vs {want}", volume(&mut s)); assert!(contains(&s, [150.0, -2.0, 20.0]) && !contains(&s, [50.0, -2.0, 20.0])); let state = s.model.state(); let body = &state.bodies[0].body; assert!(body.validity().is_empty() && body.shell_count() == 1); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({})); assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{command}: {}", volume(&mut s)); } } /// A 200 x 100 plate (2 mm, K 0.5) with `cut`, a rectangle between two corners, cut through it. fn plate_with_a_cut(s: &mut Session, cut: [[f64; 2]; 2]) { run(s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); base(s, 200.0, 100.0); rectangle_tool(s, "Cut", cut[0], cut[1]); run(s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "direction": "symmetric", "operation": "cut"})); } #[test] fn edge_flanges_on_either_side_of_a_notch_span_the_side_they_were_picked_on() { // A notch 40 wide and 30 deep in the middle of the bottom edge leaves x 0 … 80 and 120 … 200 of // it. A 90° flange 40 high set inside (setback 3, BA π, panel 37) spans the side it was picked // on and takes its setback off that side alone; picked on both sides, each has its own. for (edges, sides) in [(json!([[160, 0, 2]]), [false, true]), (json!([[40, 0, 2]]), [true, false]), (json!([[40, 0, 2], [160, 0, 2]]), [true, true])] { let mut s = Session::default(); plate_with_a_cut(&mut s, [[80.0, -10.0], [120.0, 30.0]]); run(&mut s, "sheet.flange", json!({"edges": edges, "height": 40, "angle": 90})); assert!(no_errors_or_warnings(&s), "{edges}: {:?}", s.model.results); let f = flat(&mut s); let want: Vec<(f64, f64)> = [(0.0, 80.0), (120.0, 200.0)].into_iter().zip(sides).filter_map(|(span, on)| on.then_some(span)).collect(); let bends: Vec<(f64, f64)> = f["bends"] .as_array() .cloned() .unwrap_or_default() .iter() .map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN))) .collect(); assert!( bends.len() == want.len() && bends.iter().zip(&want).all(|(b, w)| (b.0 - w.0).abs() < 1e-9 && (b.1 - w.1).abs() < 1e-9), "{edges}: {f}" ); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{edges}: {f}"); // The plate less the notch, and for each flange 80 × (π + 37) less the 80 × 3 it set back. let area = 20_000.0 - 40.0 * 30.0 + want.len() as f64 * 80.0 * (PI + 37.0 - 3.0); assert!((flat_area(&f) - area).abs() < 1e-6, "{edges}: {}", flat_area(&f)); assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}: {} vs {}", volume(&mut s), area * 2.0); // A flange stands with its outer face on the edge (y 0 … 2); a side without one keeps the // base up to the edge, which the bend's round outer corner leaves free. for (x, on) in [40.0, 160.0].into_iter().zip(sides) { assert_eq!(contains(&s, [x, 1.0, 20.0]), on, "{edges}: a flange at x {x}"); assert_eq!(contains(&s, [x, 0.2, 0.2]), !on, "{edges}: the base's own edge at x {x}"); } let state = s.model.state(); let body = &state.bodies[0].body; assert!(body.validity().is_empty() && body.shell_count() == 1, "{edges}"); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({})); assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}, {command}: {}", volume(&mut s)); } } // An edge that a cut has taken whole is refused. let mut s = Session::default(); plate_with_a_cut(&mut s, [[-10.0, -10.0], [210.0, 30.0]]); let e = s.execute("sheet.flange", &json!({"edges": [[100, 0, 2]], "height": 40})).map(|_| ()).unwrap_err().to_string(); assert!(e.contains("a cut-out has taken that edge away"), "{e}"); } #[test] fn an_edge_flange_beside_a_ledge_as_tall_as_its_setback_or_a_little_taller_folds() { // A 100 x 60 plate (2 mm, K 0.5) with a ledge 3 wide beside the right end of its bottom edge, // and a 90° flange 20 high on that edge, radius 1: setback 3 inside, half of it in the // middle; BA π, panel 17. A ledge as tall as the setback goes with the strip the setback // takes, and the bend runs along what the base keeps, x 0 … 97. One 0.05 taller keeps that // sliver above the bend line, and the bend runs on under it, x 0 … 100. Both used to fail // with "sew: a degenerate edge": the first square to the axes, the second turned off them // (a 5-12-13 slope) in the middle. for turn in [0.0, (5.0f64 / 13.0).asin()] { let at = |x: f64, y: f64| [x * turn.cos() - y * turn.sin(), x * turn.sin() + y * turn.cos()]; for (position, setback) in [("inside", 3.0), ("middle", 3.0 / 2.0)] { for (taller, len) in [(0.0, 97.0), (0.05, 100.0)] { let what = format!("{position}, ledge {setback} + {taller}, turned {turn:.4}"); let ledge = setback + taller; let mut s = Session::default(); run( &mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}), ); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); let outline = [(0.0, 0.0), (100.0, 0.0), (100.0, ledge), (97.0, ledge), (97.0, 60.0), (0.0, 60.0)].map(|(x, y)| at(x, y)); run(&mut s, "sketch.line", json!({"points": outline, "closed": true})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Outline"})); let [x, y] = at(50.0, 0.0); run(&mut s, "sheet.flange", json!({"edges": [[x, y, 2]], "height": 20, "angle": 90, "radius": 1, "position": position})); assert!(no_errors_or_warnings(&s), "{what}: {:?}", s.model.results); let f = flat(&mut s); let bends = f["bends"].as_array().cloned().unwrap_or_default(); assert!(bends.len() == 1 && (bends[0]["length"].as_f64().unwrap_or(0.0) - len).abs() < 1e-6, "{what}: {f}"); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{what}: {f}"); // The base 97 wide above the bend line (and the sliver), and len × (π + 17) of bend // and flange. let area = 97.0 * (60.0 - setback) + 3.0 * taller + len * (PI + 17.0); assert!((flat_area(&f) - area).abs() < 1e-6, "{what}: {}", flat_area(&f)); assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{what}: {} vs {}", volume(&mut s), area * 2.0); // The flange's outer face lies 3 − setback outside the edge (y setback − 3 … − 1); // it stands over the ledge only where the bend runs on. The base stops at x = 97. let here = |x: f64, y: f64, z: f64| { let [x, y] = at(x, y); contains(&s, [x, y, z]) }; assert!(here(50.0, setback - 2.0, 10.0) && here(95.0, 10.0, 1.0) && !here(98.5, 10.0, 1.0), "{what}"); assert_eq!(here(98.5, setback - 2.0, 10.0), taller > 0.0, "{what}"); let state = s.model.state(); let body = &state.bodies[0].body; assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}"); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({})); assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{what}, {command}: {}", volume(&mut s)); } } } } } #[test] fn an_edge_flange_on_a_flange_runs_along_what_a_flat_cut_left_of_its_far_edge() { // A 40 high wall on the plate's bottom edge (outside: BA π, panel 37), its far edge at // y = -(π + 37) in the flat. Unfolded, a cut takes x 0 … 60 of that edge, up to y = -30. A // 15 high flange picked on the rest of the edge (the wall's inner face is y = -1, its top // z = 40) spans x 60 … 200. let mut s = Session::default(); run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); base(&mut s, 200.0, 100.0); run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "position": "outside"})); run(&mut s, "sheet.unfold", json!({})); rectangle_tool(&mut s, "Corner", [-10.0, -50.0], [60.0, -30.0]); run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"})); run(&mut s, "sheet.refold", json!({})); run(&mut s, "sheet.flange", json!({"edges": [[130, -1, 40]], "height": 15, "angle": 90, "position": "outside"})); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); let f = flat(&mut s); let bends = f["bends"].as_array().cloned().unwrap_or_default(); let spans: Vec<(f64, f64)> = bends.iter().map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN))).collect(); let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9; assert!(spans.len() == 2 && close(spans[0], (0.0, 200.0)) && close(spans[1], (60.0, 200.0)), "{f}"); // The plate, the wall less the cut's 60 × (π + 7), and 140 × (π + 12) of the flange on it. let area = 20_000.0 + 200.0 * (PI + 37.0) - 60.0 * (PI + 7.0) + 140.0 * (PI + 12.0); assert!((flat_area(&f) - area).abs() < 1e-6, "{}", flat_area(&f)); assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{} vs {}", volume(&mut s), area * 2.0); // It lies over the plate from the wall (z 41 … 43, y 0 … 12), on x 60 … 200 only. assert!(contains(&s, [130.0, 6.0, 42.0]) && !contains(&s, [30.0, 6.0, 42.0])); } #[test] fn a_flange_bent_inside_keeps_the_base_sketchs_arcs_in_the_flat_pattern_and_the_dxf() { // A 200 x 100 plate with its corners rounded 8, drawn as arcs, and a 40 high flange on its // bottom edge (2 mm, R 1, K 0.5: setback 3 inside, half of it in the middle; BA π, panel // 37). With the bend inside or in the middle the base is cut back through the two lower // arcs: the DXF's outline was about 150 LINEs then, every arc as chords a quarter // millimetre long, where the bend outside gives 8 LINEs and 4 ARCs. let h = 8.0 * (1.0 - 0.5f64.sqrt()); // ∫ √(64 − z²): the area under an arc of radius 8, from its centre's height on. let under = |z: f64| z / 2.0 * (64.0 - z * z).sqrt() + 32.0 * (z / 8.0).asin(); for (position, setback) in [("outside", 0.0f64), ("middle", 3.0 / 2.0), ("inside", 3.0)] { let mut s = Session::default(); run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); run(&mut s, "sketch.line", json!({"points": [[8, 0], [192, 0]]})); run(&mut s, "sketch.arc.three_point", json!({"start": [192, 0], "end": [200, 8], "through": [200.0 - h, h]})); run(&mut s, "sketch.line", json!({"points": [[200, 8], [200, 92]]})); run(&mut s, "sketch.arc.three_point", json!({"start": [200, 92], "end": [192, 100], "through": [200.0 - h, 100.0 - h]})); run(&mut s, "sketch.line", json!({"points": [[192, 100], [8, 100]]})); run(&mut s, "sketch.arc.three_point", json!({"start": [8, 100], "end": [0, 92], "through": [h, 100.0 - h]})); run(&mut s, "sketch.line", json!({"points": [[0, 92], [0, 8]]})); run(&mut s, "sketch.arc.three_point", json!({"start": [0, 8], "end": [8, 0], "through": [h, h]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "S"})); run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "position": position})); assert!(no_errors_or_warnings(&s), "{position}: {:?}", s.model.results); let text = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); let shapes = solidcraft_io::read_dxf(&text).unwrap(); let lines = shapes.iter().filter(|g| matches!(g, solidcraft_io::Geom2::Line(..))).count(); let radii: Vec = shapes.iter().filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some(c.dist(*a)) } else { None }).collect(); // Eight lines of outline and the bend's centre line; the four corners. assert_eq!((lines, radii.len(), shapes.len()), (9, 4, 13), "{position}"); assert!(radii.iter().all(|r| (r - 8.0).abs() < 1e-9), "{position}: {radii:?}"); // The plate less what lies below the bend line, ∫ 184 + 2·√(64 − (8 − y)²) from 0 to the // setback, and the flange along the trimmed edge, which runs from arc to arc. let below = 184.0 * setback + 2.0 * (under(8.0) - under(8.0 - setback)); let edge = 184.0 + 2.0 * (64.0 - (8.0 - setback) * (8.0 - setback)).sqrt(); let want = 20_000.0 - (4.0 - PI) * 64.0 - below + edge * (PI + 37.0); let regions = s.model.state().sheets[0].flat_regions().unwrap(); assert!(regions.len() == 1 && (regions[0].area() - want).abs() < 1e-6, "{position}: {} vs {want}", regions[0].area()); let f = flat(&mut s); assert!((f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) - edge).abs() < 1e-9, "{position}: {}", f["bends"]); // Folded it holds the same (K 0.5), and so it does unfolded and folded again. for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!(rel(volume(&mut s), want * 2.0) < 1e-3, "{position} {command:?}: {} vs {}", volume(&mut s), want * 2.0); } } } #[test] fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() { let mut s = Session::default(); run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "thickness": 2, "bend_radius": 3, "k_factor": 0.4})); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20})); let undo = s.undo.len(); let csv = run(&mut s, "sheet.export_bend_table", json!({})); let text = csv["text"].as_str().unwrap(); assert!(text.starts_with("body,rule,thickness_mm,k_factor,bend,angle_deg,direction,")); assert!(text.contains("\"Shop, \"\"steel\"\"\""), "{text}"); assert_eq!(text.lines().count(), 2); assert_eq!(csv["bends"], 1); let exported = run(&mut s, "sheet.export_bend_table", json!({"format": "JSON"})); let table: Value = serde_json::from_str(exported["text"].as_str().unwrap()).unwrap(); assert_eq!(table["thickness"], 2.0); assert_eq!(table["bends"][0]["direction"], "up"); assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 3.8).abs() < 1e-9); assert_eq!(s.undo.len(), undo, "export does not create an undo step"); run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "k_factor": 0.5})); let updated = run(&mut s, "sheet.export_bend_table", json!({"format": "json"})); let table: Value = serde_json::from_str(updated["text"].as_str().unwrap()).unwrap(); assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 4.0).abs() < 1e-9); assert!(s.execute("sheet.export_bend_table", &json!({"format": "xml"})).is_err()); assert!(s.execute("sheet.export_bend_table", &json!({"body": "Missing"})).is_err()); let mut empty = Session::default(); assert!(empty.execute("sheet.export_bend_table", &json!({})).is_err()); } #[test] fn malformed_fold_points_are_rejected_atomically() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let before = serde_json::to_value(&s.doc).unwrap(); for points in [ json!([[40, 0, 2.5], null, [40, 60, 2.5]]), json!([[40, 0, 2.5], [40, 60, 2.5], "junk"]), json!([[40, 0, 2.5], [40, 60]]), json!([[40, 0, 2.5]]), json!("not points"), ] { assert!(s.execute("sheet.fold", &json!({"points": points})).is_err()); assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); } } #[test] fn flat_dxf_preserves_exact_cutout_entities() { use solidcraft_geom::{Loop2, Region2, Seg2, Vec2}; let mut s = Session::default(); base(&mut s, 100.0, 60.0); let mut sh = s.model.state().sheets[0].clone(); sh.holes = vec![Region2 { outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] }, holes: Vec::new(), }]; let dxf = super::flat_dxf(&sh).unwrap(); assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}"); assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC"); let read = solidcraft_io::read_dxf(&dxf).unwrap(); assert_eq!(read.len(), 5); assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Circle(c, r) if *c == Vec2::new(40.0, 30.0) && (*r - 5.0).abs() < 1e-9))); sh.holes[0].outer = Loop2 { segs: vec![ Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(30.0, 10.0) }, Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI }, ], }; let dxf = super::flat_dxf(&sh).unwrap(); assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line"); assert_eq!(dxf.matches("0\nARC\n").count(), 1); assert!(dxf.contains("50\n0.000000\n51\n180.000000\n")); let read = solidcraft_io::read_dxf(&dxf).unwrap(); assert_eq!(read.len(), 6); assert!(read.iter().any(|g| matches!(g, solidcraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9))); } // A profile chosen by a point outside its inner loop must keep that inner loop through // sheet construction, flattening and manufacturing export. #[test] fn inherited_base_profile_holes_survive_every_sheet_state() { for flip in [false, true] { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [60, 40]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip})); assert!((volume(&mut s) - 14000.0).abs() < 1e-6); let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert_eq!(solidcraft_io::read_dxf(&dxf).unwrap().len(), 8); let mut reopened = Session::default(); run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); run(&mut reopened, "sketch.finish", json!({})); let pick = if flip { [10, -10] } else { [10, 10] }; run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": pick}], "distance": 2.5})); assert!((volume(&mut reopened) - 14000.0).abs() < 1e-5); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - 14000.0).abs() < 1e-6); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - 14000.0).abs() < 1e-6); } } #[test] fn edge_notch_uses_the_same_material_boundary_in_solid_flat_and_dxf() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Notch"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, 20], "p1": [10, 40]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Notch", "through_all": true, "operation": "cut", "direction": "symmetric"})); assert!((volume(&mut s) - 14500.0).abs() < 1e-6); let f = flat(&mut s); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1)); assert_eq!(f["outline"][0].as_array().map(Vec::len), Some(8)); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0)); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert_eq!(solidcraft_io::read_dxf(&dxf).unwrap().len(), 8); let mut reopened = Session::default(); run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); run(&mut reopened, "sketch.finish", json!({})); run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "distance": 2.5})); assert!((volume(&mut reopened) - 14500.0).abs() < 1e-5); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - 14500.0).abs() < 1e-6); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - 14500.0).abs() < 1e-6); } #[test] fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() { for flip in [false, true] { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip})); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, if flip { -2.5 } else { 2.5 }]], "height": 20})); let folded = volume(&mut s); assert!((folded - (18972.62196081845 - PI * 25.0 * 2.5)).abs() < 0.2, "{folded}"); let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); let sh = s.model.state().sheets[0].clone(); let regions = sh.flat_regions().unwrap(); assert_eq!(regions[0].holes[0].segs.len(), 2); assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); let expected = regions.iter().map(solidcraft_geom::Region2::area).sum::() * 2.5; run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - expected).abs() < 0.1); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - folded).abs() < 0.1); } } fn faces(s: &mut Session) -> (u64, u64) { let body = run(s, "inspect.measure", json!({}))["bodies"][0].clone(); (body["faces"].as_u64().unwrap(), body["face_types"]["cylinder"].as_u64().unwrap_or(0)) } #[test] fn elliptic_cut_out_stays_exact_in_the_flat_pattern_and_follows_arcs_when_folded() { use solidcraft_geom::Seg2; let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); let (uncut, blank) = (volume(&mut s), s.model.state().sheets[0].flat_regions().unwrap()[0].area()); let (plain_faces, plain_cylinders) = faces(&mut s); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"})); run(&mut s, "sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"})); // The ellipse takes π·15·8 of the 2.5 mm sheet (a body's volume is measured on its mesh). let cut = PI * 15.0 * 8.0; let folded = volume(&mut s); assert!((uncut - folded - cut * 2.5).abs() < 0.5, "{}", uncut - folded); // The flat pattern holds the ellipse itself, so its area is the blank less π·15·8. let regions = s.model.state().sheets[0].flat_regions().unwrap(); let hole = ®ions[0].holes[0]; assert!(hole.segs.len() == 4 && hole.segs.iter().all(|x| matches!(x, Seg2::Conic { .. })), "{} segments", hole.segs.len()); assert!((regions[0].area() - (blank - cut)).abs() < 1e-9, "{}", regions[0].area()); // The flat pattern JSON lists it as those four curves, one `polyline` each, with every // point on the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1. let shapes = flat(&mut s)["cutout_shapes"].as_array().cloned().unwrap_or_default(); let curves: Vec> = shapes[0]["segments"].as_array().into_iter().flatten().filter_map(|x| x["polyline"].as_array().cloned()).collect(); assert!(shapes.len() == 1 && curves.len() == 4 && shapes[0]["segments"].as_array().is_some_and(|a| a.len() == 4), "{} curves", curves.len()); let off = |p: &Value| ((p[0].as_f64().unwrap_or(0.0) - 40.0) / 15.0).powi(2) + ((p[1].as_f64().unwrap_or(0.0) - 30.0) / 8.0).powi(2) - 1.0; assert!(curves.iter().all(|c| c.len() > 2 && c.iter().all(|p| off(p).abs() < 1e-9))); // Folded, the cut-out's wall is cylinders over tangent arcs: fewer than half the 264 // chords of 0.001 mm (four quarters of √(2·17/0.008) = 66 each). let (cut_faces, cut_cylinders) = faces(&mut s); let wall = cut_faces - plain_faces; assert!(wall < 132 && cut_cylinders - plain_cylinders + 4 >= wall, "{wall} wall faces, {} cylinders", cut_cylinders - plain_cylinders); // Unfolded, it is the four conic faces of the ellipse. run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - (blank - cut) * 2.5).abs() < 0.5); assert_eq!(faces(&mut s).0, regions[0].outer.segs.len() as u64 + 4 + 2); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - folded).abs() < 0.1); } #[test] fn flipped_base_flange_keeps_its_elliptic_outline() { for flip in [false, true] { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Oval"})); run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [25, 0], "minor_radius": 12})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Oval", "flip": flip})); let regions = s.model.state().sheets[0].flat_regions().unwrap(); let outline = ®ions[0].outer.segs; assert!( outline.len() == 4 && outline.iter().all(|x| matches!(x, solidcraft_geom::Seg2::Conic { .. })), "flip {flip}: {} segments", outline.len() ); // π·25·12 of the 2.5 mm sheet: exactly in the flat pattern; within 0.5 mm³ in the body, // which follows the outline by arcs and is measured on its mesh (a 64-sided outline // would be 3.8 mm³ short). assert!((regions[0].area() - PI * 300.0).abs() < 1e-9, "flip {flip}: {}", regions[0].area()); assert!((volume(&mut s) - PI * 300.0 * 2.5).abs() < 0.5, "flip {flip}: {}", volume(&mut s)); } } #[test] fn crossing_only_disconnecting_cut_preserves_all_material_states() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Split"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45, -5], "p1": [55, 65]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Split", "through_all": true, "operation": "cut", "direction": "symmetric"})); assert!((volume(&mut s) - 13500.0).abs() < 1e-6); assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); let mut reopened = Session::default(); run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "SplitBlank"})); run(&mut reopened, "sketch.finish", json!({})); run(&mut reopened, "solid.extrude", json!({"sketch": "SplitBlank", "distance": 2.5})); assert!((volume(&mut reopened) - 13500.0).abs() < 1e-6); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - 13500.0).abs() < 1e-6); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - 13500.0).abs() < 1e-6); } #[test] fn splitting_an_unfolded_flanged_sheet_preserves_both_bent_pieces() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.flange", json!({"edges": [[50,0,2.5]], "height": 20})); let folded = volume(&mut s); run(&mut s, "sheet.unfold", json!({})); let unfolded = volume(&mut s); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BlankSplit"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45,-100], "p1": [55,100]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "BlankSplit", "through_all": true, "operation": "cut", "direction": "symmetric"})); assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1); assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - folded * 0.9).abs() < 0.2); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1); } #[test] fn repeated_and_nested_sheet_cuts_are_redundant() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); for (i, radius) in [3.0, 5.0, 5.0].into_iter().enumerate() { let name = format!("Hole{i}"); run(&mut s, "sketch.create", json!({"plane": "XY", "name": name})); run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": radius})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"})); } assert!((volume(&mut s) - (6000.0 - PI * 25.0) * 2.5).abs() < 0.1); assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); } #[test] fn overlapping_round_cuts_keep_exact_union_in_export_and_roundtrip() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); for (i, x) in [20.0, 22.0].into_iter().enumerate() { let name = format!("Overlap{i}"); run(&mut s, "sketch.create", json!({"plane": "XY", "name": name})); run(&mut s, "sketch.circle.center", json!({"center": [x, 30], "radius": 5})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"})); } let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt(); let expected = (6000.0 - (50.0 * PI - intersection)) * 2.5; assert!((volume(&mut s) - expected).abs() < 0.1); assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert!(dxf.contains("0\nARC\n")); let mut reopened = Session::default(); run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"})); run(&mut reopened, "sketch.finish", json!({})); run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": [10, 10]}], "distance": 2.5})); assert!((volume(&mut reopened) - expected).abs() < 0.1); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - expected).abs() < 0.1); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - expected).abs() < 0.1); } #[test] fn cut_across_a_concave_notch_uses_crossings_not_just_contained_corners() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "U"})); run(&mut s, "sketch.line", json!({"points": [[0,0],[100,0],[100,60],[70,60],[70,20],[30,20],[30,60],[0,60],[0,0]]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "U"})); assert!((volume(&mut s) - 11000.0).abs() < 1e-6); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ArmNotches"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,30], "p1": [80,40]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "ArmNotches", "through_all": true, "operation": "cut", "direction": "symmetric"})); assert!((volume(&mut s) - 10500.0).abs() < 1e-6); let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0)); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - 10500.0).abs() < 1e-6); } #[test] fn tiny_cut_inside_exact_circle_survives_unfold_refold_and_dxf() { for initially_flat in [false, true] { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Disc"})); run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 10})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Disc"})); let circle = s.model.state().sheets[0].base.outer.clone(); let displayed = circle.polyline(1e-3); let at = (displayed[0] + displayed[1]).normalized().unwrap() * 9.9998; assert!(!circle.contains(at)); if initially_flat { run(&mut s, "sheet.unfold", json!({})); } run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TinyCut"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [at.x-0.000025,at.y-0.000025], "p1": [at.x+0.000025,at.y+0.000025]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "TinyCut", "through_all": true, "operation": "cut", "direction": "symmetric"})); for stage in ["sheet.unfold", "sheet.refold"] { let regions = s.model.state().sheets[0].flat_regions().unwrap(); assert_eq!(regions[0].holes.len(), 1); assert!((regions[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13); assert_eq!(s.model.state().bodies.len(), 1); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert_eq!(solidcraft_io::read_dxf(&dxf).unwrap().len(), 5); if stage == "sheet.unfold" && initially_flat { run(&mut s, "sheet.refold", json!({})); } run(&mut s, stage, json!({})); } assert_eq!(s.model.state().sheets[0].flat_regions().unwrap()[0].holes.len(), 1); } } #[test] fn unrelated_box_cut_preserves_logical_ring_sheet_compound() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let sheet_name = s.model.state().sheets[0].body.clone(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"})); for radius in [10, 5] { run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": radius})); } run(&mut s, "sketch.finish", json!({})); run( &mut s, "solid.extrude", json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}), ); let material = s.model.state().sheets[0].flat_regions().unwrap(); run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [200,0,0], "body_name": "SeparateBox"})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BoxCut"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [205,5], "p1": [210,10]})); run(&mut s, "sketch.finish", json!({})); run( &mut s, "solid.extrude", json!({"sketch": "BoxCut", "participants": ["SeparateBox"], "through_all": true, "operation": "cut", "direction": "symmetric"}), ); assert_eq!(s.model.state().bodies.len(), 2); assert_eq!(s.model.state().sheets[0].flat_regions().unwrap(), material); for stage in ["sheet.unfold", "sheet.refold"] { run(&mut s, stage, json!({"body": sheet_name})); assert_eq!(s.model.state().bodies.len(), 2); let state = s.model.state(); let body = &state.bodies.iter().find(|b| b.name == sheet_name).unwrap().body; assert!(body.contains(solidcraft_geom::Vec3::new(50.0, 30.0, 1.25))); assert!(!body.contains(solidcraft_geom::Vec3::new(57.0, 30.0, 1.25))); } } #[test] fn specialized_sheet_cuts_respect_component_and_explicit_participants() { for explicit in [false, true] { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); run(&mut s, "component.create", json!({"name": "Other"})); run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "OtherBox"})); if explicit { run(&mut s, "component.activate", json!({"component": "root"})); } run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,20], "p1": [30,30]})); run(&mut s, "sketch.finish", json!({})); let mut params = json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}); if explicit { params["participants"] = json!(["OtherBox"]); } run(&mut s, "solid.extrude", params); let m = run(&mut s, "inspect.measure", json!({"bodies": [name]})); assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 15000.0).abs() < 1e-6); assert!(s.model.state().sheets[0].holes.is_empty()); let m = run(&mut s, "inspect.measure", json!({"bodies": ["OtherBox"]})); assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 59000.0).abs() < 1e-6); } } #[test] fn ring_cut_preserves_retained_island_through_unfold_and_refold() { fn material_probes(s: &Session) { let body = &s.model.state().bodies[0].body; assert!(body.contains(solidcraft_geom::Vec3::new(50.0, 30.0, 1.25))); assert!(!body.contains(solidcraft_geom::Vec3::new(57.0, 30.0, 1.25))); assert!(body.contains(solidcraft_geom::Vec3::new(65.0, 30.0, 1.25))); } let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"})); run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 10})); 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": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}), ); let expected = (6000.0 - PI * 75.0) * 2.5; assert!((volume(&mut s) - expected).abs() < 0.2); material_probes(&s); assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2)); let regions = s.model.state().sheets[0].flat_regions().unwrap(); assert!(regions.iter().any(|r| r.contains(solidcraft_geom::Vec2::new(50.0, 30.0)))); assert!(!regions.iter().any(|r| r.contains(solidcraft_geom::Vec2::new(57.0, 30.0)))); assert!(regions.iter().any(|r| r.contains(solidcraft_geom::Vec2::new(65.0, 30.0)))); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); let mut reopened = Session::default(); run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "RingBlank"})); run(&mut reopened, "sketch.finish", json!({})); run(&mut reopened, "solid.extrude", json!({"sketch": "RingBlank", "profiles": [{"point": [10,10]}, {"point": [50,30]}], "distance": 2.5})); assert!((volume(&mut reopened) - expected).abs() < 0.2); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - expected).abs() < 0.2); material_probes(&s); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - expected).abs() < 0.2); material_probes(&s); } fn retained_depth_check(s: &mut Session, want: f64, z0: f64, z1: f64) { use solidcraft_geom::Vec3; assert!((volume(s) - want).abs() < 1e-6, "{} vs {want}", volume(s)); let state = s.model.state(); assert_eq!(state.sheets.len(), 1); assert_eq!(state.sheets[0].t, 2.5, "rule stock gauge is preserved"); assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, (z0 + z1) * 0.5)))); assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, z0 - 0.1)))); assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, z1 + 0.1)))); assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(10.0, 25.0, (z0 + z1) * 0.5)))); let bounds = state.bodies[0].mesh().bounds(); assert!((bounds.min.z - z0).abs() < 1e-8 && (bounds.max.z - z1).abs() < 1e-8, "{bounds:?}"); } fn depth_step_reopen(s: &mut Session, suffix: &str, want: f64, inside: solidcraft_geom::Vec3, outside: solidcraft_geom::Vec3) { let path = std::env::temp_dir().join(format!("solidcraft-depth-intersect-{suffix}-{}.step", std::process::id())); run(s, "file.export", json!({"path":path.to_string_lossy()})); let mut imported = Session::default(); run(&mut imported, "doc.open", json!({"path":path.to_string_lossy()})); assert!((volume(&mut imported) - want).abs() < 1e-6); assert!(imported.model.state().bodies.iter().any(|b| b.body.contains(inside))); assert!(!imported.model.state().bodies.iter().any(|b| b.body.contains(outside))); std::fs::remove_file(path).unwrap(); } /// The generic sketch importer intentionally drops DXF layers/depth. Reimport only the /// material outline at a separately known depth, never pretend all entities reconstruct3D. fn dxf_outline_only(text: &str) -> String { let lines: Vec<_> = text.lines().collect(); let pairs: Vec<_> = lines.as_chunks::<2>().0.iter().filter_map(|p| Some((p.first()?.trim(), *p.get(1)?))).collect(); let mut out = String::from("0\nSECTION\n2\nENTITIES\n"); let mut start = 0; for end in 1..=pairs.len() { if end == pairs.len() || pairs.get(end).is_some_and(|p| p.0 == "0") { let entity = &pairs[start..end]; if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE")) && entity.iter().any(|p| p.0 == "8" && p.1 == "OUTLINE") { for (code, value) in entity { out.push_str(&format!("{code}\n{value}\n")); } } start = end; } } out.push_str("0\nENDSEC\n0\nEOF\n"); out } #[test] fn partial_depth_intersect_retains_bottom_top_and_middle_material_through_history_and_exports() { use solidcraft_geom::Vec3; for (offset, z0, z1, layers) in [ (0.0, 0.0, 1.0, vec![("top", 1.0, 2.5)]), (1.5, 1.5, 2.5, vec![("bottom", 0.0, 1.5)]), (0.5, 0.5, 1.5, vec![("bottom", 0.0, 0.5), ("top", 1.5, 2.5)]), ] { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":offset,"operation":"intersect","name":"RetainSlab"})); retained_depth_check(&mut s, 100.0, z0, z1); let exported = flat(&mut s); assert_eq!(exported["thickness"], 2.5); assert_eq!(exported["pockets"].as_array().unwrap().len(), layers.len()); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); for (side, a, b) in &layers { assert!( exported["pockets"] .as_array() .unwrap() .iter() .any(|p| p["side"] == *side && p["z0_mm"] == *a && p["z1_mm"] == *b && p["depth_mm"] == b - a) ); assert!(dxf.contains(&format!("POCKET_{}_Z{a:.6}_TO_{b:.6}_MM", side.to_uppercase()))); } let outline = dxf_outline_only(&dxf); assert_eq!(solidcraft_io::read_dxf(&outline).unwrap().len(), 4); let mut outline_import = Session::default(); run(&mut outline_import, "sketch.insert_dxf", json!({"text":outline,"name":"Outline"})); run(&mut outline_import, "sketch.finish", json!({})); run(&mut outline_import, "solid.extrude", json!({"distance":1})); assert!((volume(&mut outline_import) - 100.0).abs() < 1e-6); run(&mut s, "edit.undo", json!({})); assert!((volume(&mut s) - 15000.0).abs() < 1e-6); run(&mut s, "edit.redo", json!({})); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({})); retained_depth_check(&mut s, 100.0, z0, z1); assert_eq!(flat(&mut s)["pockets"], exported["pockets"]); } let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); retained_depth_check(&mut reopened, 100.0, z0, z1); depth_step_reopen(&mut s, &format!("offset{offset}"), 100.0, Vec3::new(25.0, 25.0, (z0 + z1) * 0.5), Vec3::new(25.0, 25.0, z1 + 0.1)); } } #[test] fn partial_depth_intersect_preserves_prior_pockets_and_actual_interval_intersections() { use solidcraft_geom::Vec3; let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "OldPocket", [20.0, 20.0], [25.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"OldPocket","distance":1,"operation":"cut"})); rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!((volume(&mut s) - 75.0).abs() < 1e-6); let state = s.model.state(); let body = &state.bodies[0].body; assert!(!body.contains(Vec3::new(22.0, 25.0, 0.75))); assert!(body.contains(Vec3::new(22.0, 25.0, 1.25))); assert!(body.contains(Vec3::new(27.0, 25.0, 0.75))); assert!(!body.contains(Vec3::new(27.0, 25.0, 0.25))); } let records = s.model.state().sheets[0].pockets.clone(); run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1.5,"start_offset":0.25,"operation":"intersect"})); assert!((volume(&mut s) - 75.0).abs() < 1e-6); assert_eq!(s.model.state().sheets[0].pockets, records, "no-change leaves replay work unchanged"); run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":0.25,"start_offset":1.0,"operation":"intersect"})); assert!((volume(&mut s) - 25.0).abs() < 1e-6); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - 25.0).abs() < 1e-6); let mut cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!((volume(&mut cold) - 25.0).abs() < 1e-6); depth_step_reopen(&mut s, "prior", 25.0, Vec3::new(22.0, 25.0, 1.125), Vec3::new(22.0, 25.0, 0.75)); } #[test] fn partial_depth_intersect_keeps_disconnected_tool_islands_and_annular_curves() { use solidcraft_geom::Vec3; let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Islands", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "sketch.edit", json!({"sketch":"Islands"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[60,20],"p1":[70,30]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch":"Islands","distance":1,"operation":"intersect"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!((volume(&mut s) - 200.0).abs() < 1e-6); assert_eq!(s.model.state().sheets[0].flat_regions().unwrap().len(), 2); for x in [25.0, 65.0] { assert!(s.model.state().bodies[0].body.contains(Vec3::new(x, 25.0, 0.5))); } assert!(!s.model.state().bodies[0].body.contains(Vec3::new(45.0, 25.0, 0.5))); } depth_step_reopen(&mut s, "islands", 200.0, Vec3::new(65.0, 25.0, 0.5), Vec3::new(45.0, 25.0, 0.5)); let mut ring = Session::default(); base(&mut ring, 100.0, 60.0); run(&mut ring, "sketch.create", json!({"plane":"XY","name":"Ring"})); for radius in [10.0, 5.0] { run(&mut ring, "sketch.circle.center", json!({"center":[50,30],"radius":radius})); } run(&mut ring, "sketch.finish", json!({})); run(&mut ring, "solid.extrude", json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"distance":1,"operation":"intersect"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut ring, command, json!({})); } let state = ring.model.state(); let area: f64 = state.sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); assert!((area - 75.0 * PI).abs() < 1e-8); assert!((volume(&mut ring) - 75.0 * PI).abs() < 0.1); assert!(state.bodies[0].body.contains(Vec3::new(57.0, 30.0, 0.5))); assert!(!state.bodies[0].body.contains(Vec3::new(50.0, 30.0, 0.5))); assert!(!state.bodies[0].body.contains(Vec3::new(57.0, 30.0, 1.5))); } let dxf = run(&mut ring, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); assert!(dxf.contains("POCKET_TOP_Z1.000000_TO_2.500000_MM")); assert_eq!(solidcraft_io::read_dxf(&dxf_outline_only(&dxf)).unwrap().len(), 2); } #[test] fn partial_depth_intersect_deletes_material_already_removed_by_a_pocket_atomically() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "Removed", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Removed","distance":1,"operation":"cut"})); run(&mut s, "solid.extrude", json!({"sketch":"Removed","distance":1,"operation":"intersect"})); assert!(s.model.state().bodies.is_empty()); assert!(s.model.state().sheets.is_empty()); run(&mut s, "edit.undo", json!({})); assert!((volume(&mut s) - 14900.0).abs() < 1e-6); run(&mut s, "edit.redo", json!({})); assert!(s.model.state().bodies.is_empty()); } #[test] fn partial_depth_intersect_flipped_tool_and_transferred_sheet_keep_depth_side_and_world_pose() { use solidcraft_geom::Vec3; let mut flipped = Session::default(); base(&mut flipped, 100.0, 60.0); run(&mut flipped, "sketch.create", json!({"name":"FlippedTool","plane":{"origin":[0,0,2.5],"x_dir":[1,0,0],"y_dir":[0,-1,0]}})); run(&mut flipped, "sketch.rectangle.two_point", json!({"p0":[20,-30],"p1":[30,-20]})); run(&mut flipped, "sketch.finish", json!({})); run(&mut flipped, "solid.extrude", json!({"sketch":"FlippedTool","distance":1,"operation":"intersect"})); retained_depth_check(&mut flipped, 100.0, 1.5, 2.5); assert_eq!(flat(&mut flipped)["pockets"][0]["side"], "bottom"); assert_eq!(flat(&mut flipped)["pockets"][0]["depth_mm"], 1.5); let mut s = Session::default(); base(&mut s, 100.0, 60.0); rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect","name":"RetainSlab"})); let before = s.world_state().bodies[0].mesh().bounds(); let name = s.model.state().sheets[0].body.clone(); let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0})); let owner = destination["component"].as_u64().unwrap(); let occurrence = destination["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } let state = s.model.state(); let sh = &state.sheets[0]; assert_eq!(sh.t, 2.5); assert!(sh.frame.origin.dist(Vec3::new(-30.0, -10.0, 20.0)) < 1e-8); assert!(sh.frame.normal().dist(Vec3::Y) < 1e-8); assert_eq!(sh.pockets.len(), 2); assert_eq!(state.body(&name).unwrap().component, owner); assert!(state.body(&name).unwrap().transferred); let world = s.world_state(); let body = &world.body(&name).unwrap().body; assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); assert!(!body.contains(Vec3::new(25.0, 25.0, 0.25))); assert!(!body.contains(Vec3::new(25.0, 25.0, 2.0))); let bounds = world.body(&name).unwrap().mesh().bounds(); assert!(before.min.dist(bounds.min) < 1e-8 && before.max.dist(bounds.max) < 1e-8); assert!((solidcraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6); } let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); let reopened_world = reopened.world_state(); let body = &reopened_world.body(&name).unwrap().body; assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); assert!((solidcraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6); run(&mut reopened, "timeline.edit", json!({"feature":"RetainSlab","set":{"extent":{"distance":"1.5"}}})); let world = reopened.world_state(); let body = &world.body(&name).unwrap().body; assert!((solidcraft_kernel::measure(body).unwrap().volume - 150.0).abs() < 1e-6); assert!(body.contains(Vec3::new(25.0, 25.0, 1.75))); assert!(!body.contains(Vec3::new(25.0, 25.0, 2.25))); } #[test] fn partial_depth_intersect_full_gauge_in_rotated_frames_does_not_invent_pockets() { use solidcraft_geom::Vec3; for (angle, translate) in [(37.0, [30.0, 20.0, 10.0]), (121.0, [12345.678, 98765.432, 4321.123])] { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0})); let owner = destination["component"].as_u64().unwrap(); run( &mut s, "occurrence.move", json!({"occurrence":destination["occurrence"],"translate":translate,"axis":[1,2,3],"angle":format!("{angle} deg"),"capture":true}), ); run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]})); run(&mut s, "component.activate", json!({"component":owner})); run(&mut s, "sketch.create", json!({"name":"WorldTool","plane":{"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,1,0]}})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})); run(&mut s, "sketch.finish", json!({})); let mut tool_session = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); // The witness is built in the sketch's own component, so its caps lie in the sketch's frame. run(&mut tool_session, "component.activate", json!({"component":owner})); run(&mut tool_session, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"body_name":"CapWitness"})); let witness_state = tool_session.model.state(); let witness = &witness_state.body("CapWitness").unwrap().body; let tool_plane = witness_state.sketches.last().unwrap().plane; let sources = witness.face_sources().unwrap(); assert_eq!(sources.len(), witness.face_count()); let mut cap_count = 0; for (index, source) in sources.iter().enumerate() { if let Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { high, plane, .. }) = source { cap_count += 1; let current = witness.planar_face(index).unwrap(); assert!(current.normal().cross(tool_plane.normal()).len() < 1e-10); assert!((current.origin - plane.origin).dot(tool_plane.normal()).abs() < 1e-8); assert!(((plane.origin - tool_plane.origin).dot(tool_plane.normal()) - if *high { 2.5 } else { 0.0 }).abs() < 1e-8); } } assert_eq!(cap_count, 2); run(&mut s, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"operation":"intersect","name":"PostTransferRetain"})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert!(s.model.state().sheets[0].pockets.is_empty(), "full gauge must not acquire roundoff-depth pockets"); let world = s.world_state(); let body = &world.body(&name).unwrap().body; assert!((solidcraft_kernel::measure(body).unwrap().volume - 250.0).abs() < 1e-5); assert!(body.contains(Vec3::new(25.0, 25.0, 0.1))); assert!(body.contains(Vec3::new(25.0, 25.0, 2.4))); assert!(!body.contains(Vec3::new(10.0, 25.0, 1.0))); } let mut cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!(cold.model.state().sheets[0].pockets.is_empty()); assert!(flat(&mut cold)["pockets"].as_array().unwrap().is_empty()); run(&mut s, "timeline.edit", json!({"feature":"PostTransferRetain","set":{"extent":{"distance":"1","start_offset":"0.5"}}})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({})); } assert_eq!(s.model.state().sheets[0].pockets.len(), 2); let world = s.world_state(); let body = &world.body(&name).unwrap().body; let measured = solidcraft_kernel::measure(body).unwrap().volume; assert!((measured - 100.0).abs() < 1e-5, "retained volume {measured}"); assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1))); assert!(!body.contains(Vec3::new(25.0, 25.0, 2.4))); } let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); let world = cold.world_state(); let body = &world.body(&name).unwrap().body; assert!((solidcraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-5); assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1))); } } #[test] fn partial_depth_intersect_planar_to_object_retains_current_target_depth() { use solidcraft_geom::Vec3; let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); run(&mut s, "solid.box", json!({"corner":[200,0,1.5],"length":10,"width":10,"height":1,"body_name":"Roof"})); rectangle_tool(&mut s, "Tool", [20.0, 20.0], [30.0, 30.0]); run(&mut s, "solid.extrude", json!({"sketch":"Tool","to":[205,5,1.5],"start_offset":0.5,"operation":"intersect","participants":[name]})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({"body":name})); } let state = s.model.state(); let body = &state.body(&name).unwrap().body; assert!((solidcraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6); assert!(body.contains(Vec3::new(25.0, 25.0, 1.0))); assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1))); assert!(!body.contains(Vec3::new(25.0, 25.0, 2.0))); assert_eq!(state.sheets[0].pockets.len(), 2); assert!((solidcraft_kernel::measure(&state.body("Roof").unwrap().body).unwrap().volume - 100.0).abs() < 1e-6); } let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!((solidcraft_kernel::measure(&cold.model.state().body(&name).unwrap().body).unwrap().volume - 100.0).abs() < 1e-6); } #[test] fn partial_depth_intersect_curved_to_object_refuses_unrepresentable_depth_atomically() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); run(&mut s, "solid.cylinder", json!({"base":[25,20,12],"axis":[0,1,0],"radius":11,"height":10,"body_name":"CurvedRoof"})); rectangle_tool(&mut s, "Tool", [24.0, 23.0], [26.0, 27.0]); let before = s.doc.to_json(); let error = s.execute("solid.extrude", &json!({"sketch":"Tool","to":[36,25,12],"operation":"intersect","participants":[name]})).unwrap_err().to_string(); assert!(error.contains("sheet machining"), "{error}"); assert_eq!(s.doc.to_json(), before); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({"body":name})); } let state = s.model.state(); assert!((solidcraft_kernel::measure(&state.body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-6); assert!(state.sheets[0].pockets.is_empty()); } } #[test] fn partial_depth_intersect_curved_to_object_preserves_current_trimmed_cap_footprint() { use solidcraft_geom::Vec3; let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); run(&mut s, "solid.cylinder", json!({"base":[25,25,0],"axis":[0,0,1],"radius":2,"height":20,"body_name":"CurvedRoof"})); run(&mut s, "solid.box", json!({"corner":[19,19,1],"length":12,"width":12,"height":1,"body_name":"StopPlate"})); run(&mut s, "solid.combine", json!({"target":"CurvedRoof","tools":["StopPlate"],"operation":"join"})); rectangle_tool(&mut s, "Tool", [20.0, 20.0], [30.0, 30.0]); let mut tool_session = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); run(&mut tool_session, "solid.extrude", json!({"sketch":"Tool","to":[27,25,10],"body_name":"TrimWitness"})); let ts = tool_session.model.state(); let witness = &ts.body("TrimWitness").unwrap().body; assert!((solidcraft_kernel::measure(witness).unwrap().volume - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); assert!(witness.contains(Vec3::new(21.0, 21.0, 0.5))); assert!(!witness.contains(Vec3::new(25.0, 25.0, 0.5))); run(&mut s, "solid.extrude", json!({"sketch":"Tool","to":[27,25,10],"operation":"intersect","participants":[name]})); for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { if let Some(command) = command { run(&mut s, command, json!({"body":name})); } let state = s.model.state(); let body = &state.body(&name).unwrap().body; let measured = solidcraft_kernel::measure(body).unwrap().volume; assert!((measured - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); let area: f64 = state.sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum(); assert!((area - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 1e-8); assert!(body.contains(Vec3::new(21.0, 21.0, 0.5))); assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5))); assert!(!body.contains(Vec3::new(21.0, 21.0, 1.5))); } let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!(!cold.model.state().body(&name).unwrap().body.contains(Vec3::new(25.0, 25.0, 0.5))); let dxf = run(&mut s, "sheet.export_dxf", json!({"body":name}))["dxf"].as_str().unwrap().to_string(); assert!(dxf.contains("POCKET_TOP_Z1.000000_TO_2.500000_MM")); let outline = dxf_outline_only(&dxf); assert_eq!(solidcraft_io::read_dxf(&outline).unwrap().len(), 5); let mut outline_import = Session::default(); run(&mut outline_import, "sketch.insert_dxf", json!({"text":outline,"name":"Outline"})); run(&mut outline_import, "sketch.finish", json!({})); run(&mut outline_import, "solid.extrude", json!({"distance":1,"profiles":[{"point":[21,21]}]})); assert!((volume(&mut outline_import) - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); assert!(!outline_import.model.state().bodies[0].body.contains(Vec3::new(25.0, 25.0, 0.5))); let path = std::env::temp_dir().join(format!("solidcraft-current-trim-cap-{}.step", std::process::id())); run(&mut s, "file.export", json!({"path":path.to_string_lossy(),"bodies":[name]})); let mut imported = Session::default(); run(&mut imported, "doc.open", json!({"path":path.to_string_lossy()})); assert!((volume(&mut imported) - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01); assert!(!imported.model.state().bodies[0].body.contains(Vec3::new(25.0, 25.0, 0.5))); std::fs::remove_file(path).unwrap(); } #[test] fn partial_depth_intersect_sub_extrusion_resolution_layers_refuse_atomically() { for (distance, offset) in [(2.5 - 1e-8, 1e-8), (2.5 - 1e-8, 0.0), (1e-8, 0.0)] { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); let destination = run(&mut s, "component.create", json!({"name":"Far","parent":0})); let owner = destination["component"].as_u64().unwrap(); run( &mut s, "occurrence.move", json!({"occurrence":destination["occurrence"],"translate":[12345.678,98765.432,4321.123],"axis":[1,2,3],"angle":"121 deg","capture":true}), ); run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]})); run(&mut s, "component.activate", json!({"component":owner})); run(&mut s, "sketch.create", json!({"name":"Tool","plane":{"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,1,0]}})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})); run(&mut s, "sketch.finish", json!({})); let before = s.doc.to_json(); let error = s .execute("solid.extrude", &json!({"sketch":"Tool","distance":distance,"start_offset":offset,"operation":"intersect"})) .unwrap_err() .to_string(); assert!(error.contains("resolution") || error.contains("distance is zero") || error.contains("sheet machining"), "{error}"); assert_eq!(s.doc.to_json(), before); assert!(s.model.state().sheets[0].pockets.is_empty()); assert!((solidcraft_kernel::measure(&s.world_state().body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-5); } } /// A 0.8 mm steel rule and a base flange from a closed outline on XY (the sheet spans z 0..0.8). fn outline_blank(s: &mut Session, outline: Value) { run(s, "sheet.manage_rules", json!({"name": "Steel 0.8", "thickness": "0.8", "active": true})); run(s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); run(s, "sketch.line", json!({"points": outline, "closed": true})); run(s, "sketch.finish", json!({})); run(s, "sheet.flange", json!({"sketch": "Blank"})); } /// A 100 x 40 blank with two 15 mm tabs on its right edge (y 5..12 and y 28..35), on one bend line. fn two_tab_blank(s: &mut Session) { outline_blank( s, json!([[0, 0], [100, 0], [100, 5], [115, 5], [115, 12], [100, 12], [100, 28], [115, 28], [115, 35], [100, 35], [100, 40], [0, 40]]), ); } /// Folding one tab must not be refused because another tab lies on the same line (#264). #[test] fn fold_one_of_two_collinear_tabs() { let mut s = Session::default(); two_tab_blank(&mut s); let flat = volume(&mut s); run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 12, 0.8]], "fixed": [50, 20, 0.8]})); assert!(rel(volume(&mut s), flat) < 1e-3, "a fold keeps the material"); } /// #685: a `fixed` point exactly on the sheet's top face is "on the sheet" (0.8.0 refused it, while a point 0.4 mm /// above the sheet was accepted). A frame blank 507.4 .. 508.2 with two flange tabs, folded on one of them. #[test] fn fold_accepts_a_fixed_point_on_the_top_face() { for fixed_z in [508.2, 507.8, 507.4] { let mut s = Session::default(); run(&mut s, "sheet.manage_rules", json!({"name": "SGCC 0.8", "thickness": "0.8", "k_factor": "0.44", "bend_radius": "0.8", "active": true})); run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 507.4], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Blank"})); run( &mut s, "sketch.line", json!({"points": [[1.7, 10.75], [30, 10.75], [30, 0.64], [440, 0.64], [440, 10.75], [465.5, 10.75], [465.5, 229.25], [440, 229.25], [440, 239.36], [30, 239.36], [30, 229.25], [1.7, 229.25]], "closed": true}), ); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"base": {"sketch": "Blank"}, "rule": "SGCC 0.8"})); let flat = volume(&mut s); run(&mut s, "sheet.fold", json!({"points": [[30, 230.255, 508.2], [440, 230.255, 508.2]], "fixed": [420, 120, fixed_z], "flip": true})); assert!(rel(volume(&mut s), flat) < 1e-3, "fixed z {fixed_z}: a fold keeps the material"); } } /// One fold line across both tabs bends both (#264). #[test] fn fold_across_two_collinear_tabs() { let mut s = Session::default(); two_tab_blank(&mut s); let flat = volume(&mut s); run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 35, 0.8]], "fixed": [50, 20, 0.8]})); assert!(rel(volume(&mut s), flat) < 1e-3); let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]; assert!(bb["max"][2].as_f64().unwrap() > 7.0, "both tabs stand up: {bb}"); } /// After one of two collinear tabs is folded, the other one folds in its own feature too (#638, follow-up to #264). #[test] fn fold_second_collinear_tab_after_the_first() { let mut s = Session::default(); two_tab_blank(&mut s); let flat = volume(&mut s); run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 12, 0.8]], "fixed": [50, 20, 0.8]})); run(&mut s, "sheet.fold", json!({"points": [[107, 28, 0.8], [107, 35, 0.8]], "fixed": [50, 20, 0.8]})); assert!(rel(volume(&mut s), flat) < 1e-3, "folds keep the material"); let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]; assert!(bb["max"][2].as_f64().unwrap() > 7.0, "the tabs stand up: {bb}"); // The tabs stand at the bend, x 106.1 .. 107.9 (centerline at x 107). assert!(bb["max"][0].as_f64().unwrap() < 110.0, "neither tab still lies flat out to x 115: {bb}"); } /// An L blank (100 x 50 bottom leg, 40 x 50 upright leg on the right) with a tab x 45..60, y 60..90 on the inside /// edge x 60. The fold line x 59, extended, runs into the bottom leg. fn l_blank_with_inside_tab(s: &mut Session) { outline_blank(s, json!([[0, 0], [100, 0], [100, 100], [60, 100], [60, 90], [45, 90], [45, 60], [60, 60], [60, 50], [0, 50]])); } /// The tab on the inside edge folds wherever on the L sheet `fixed` lies: also when it is on the leg the extended /// fold line runs into (#639: "folding tabs that join again beyond the bend"). #[test] fn fold_tab_on_inside_edge_of_l_blank_with_fixed_on_the_far_leg() { let mut near = Session::default(); l_blank_with_inside_tab(&mut near); run(&mut near, "sheet.fold", json!({"points": [[59, 60, 0.8], [59, 90, 0.8]], "fixed": [80, 20, 0.8]})); let mut far = Session::default(); l_blank_with_inside_tab(&mut far); let flat = volume(&mut far); run(&mut far, "sheet.fold", json!({"points": [[59, 60, 0.8], [59, 90, 0.8]], "fixed": [40, 30, 0.8]})); assert!(rel(volume(&mut far), flat) < 1e-3, "a fold keeps the material"); let a = &run(&mut near, "inspect.measure", json!({}))["bodies"][0]["bbox"]; let b = &run(&mut far, "inspect.measure", json!({}))["bodies"][0]["bbox"]; assert!(b["max"][2].as_f64().unwrap() > 7.0, "the tab stands up: {b}"); assert_eq!(a, b, "both fixed points are on the same L sheet, so the fold must give the same part"); } /// Edge flanges on both tab ends in one feature (two separate features already work) (#264). #[test] fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() { let mut s = Session::default(); two_tab_blank(&mut s); run(&mut s, "sheet.flange", json!({"edges": [[115, 8, 0.8], [115, 31, 0.8]], "height": "10"})); let mut separate = Session::default(); two_tab_blank(&mut separate); run(&mut separate, "sheet.flange", json!({"edges": [[115, 8, 0.8]], "height": "10"})); run(&mut separate, "sheet.flange", json!({"edges": [[115, 31, 0.8]], "height": "10"})); assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6); } /// A 160 x 30 bar with a 20 x 30 leg standing past its top edge at the left end (2 mm, R 1, /// K 0.5, so folding keeps the volume): a base flange, or a plate converted to sheet metal. fn bar_with_leg(s: &mut Session, converted: bool) { run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "active": true})); run(s, "sketch.create", json!({"plane": "XY", "name": "Bar"})); run(s, "sketch.line", json!({"points": [[0, 0], [160, 0], [160, 30], [20, 30], [20, 60], [0, 60]], "closed": true})); run(s, "sketch.finish", json!({})); if converted { run(s, "solid.extrude", json!({"sketch": "Bar", "distance": 2, "body_name": "Plate"})); run(s, "sheet.convert", json!({"body": "Plate", "face": [80, 15, 2]})); } else { run(s, "sheet.flange", json!({"sketch": "Bar"})); } } /// An edge flange takes its setback off its own edge only: the leg standing past the edge's end /// stays, and the rule's bend relief frees the bend and the flange from it. #[test] fn an_edge_flange_beside_a_leg_of_the_base_trims_its_own_edge_and_relieves_its_bend() { // Bend allowance (π/2)·(R + K·T) = π; the rule's relief is Thickness wide, half of it deep. let (ba, width, depth) = (PI, 2.0, 1.0); for (converted, position, setback) in [(false, "inside", 3.0), (false, "outside", 0.0), (true, "inside", 3.0)] { let what = format!("converted {converted}, {position}"); let mut s = Session::default(); bar_with_leg(&mut s, converted); // Where the sheet's bottom face lies. let z0 = bbox(&mut s)["min"][2].as_f64().unwrap_or(f64::NAN); // A 90° flange 15 high on the bar's top edge (x 20…160); the leg stands past its left end. run(&mut s, "sheet.flange", json!({"edges": [[90, 30, z0 + 2.0]], "height": 15, "angle": 90, "position": position})); assert!(no_errors_or_warnings(&s), "{what}: {:?}", s.model.results); // By hand: the base loses the setback under that edge alone, and the relief beside the // bend's end at x = 20, from `depth` before the bend line to `width` past the flange's // far edge in the flat (BA and the 12 mm panel past the line). let (line, panel) = (30.0 - setback, 12.0); let relief = width * (depth + ba + panel + width); let base = 160.0 * 30.0 + 20.0 * 30.0 - 140.0 * setback - relief; let want = 2.0 * (base + 140.0 * (ba + panel)); assert!(rel(volume(&mut s), want) < 1e-5, "{what}: {} vs {want}", volume(&mut s)); assert!(contains(&s, [10.0, 50.0, z0 + 1.0]), "{what}: the leg is kept"); assert!(!contains(&s, [19.0, line + 0.5, z0 + 1.0]), "{what}: the relief is cut"); assert!(contains(&s, [17.0, line + 0.5, z0 + 1.0]), "{what}: the leg beside the relief stays"); assert!(contains(&s, [19.0, 50.0, z0 + 1.0]), "{what}: the leg past the relief's end stays"); // The flange's mid plane is R + T/2 = 2 past the bend line; it ends at the leg. assert!(contains(&s, [90.0, line + 2.0, z0 + 10.0]), "{what}: the flange stands"); assert!(!contains(&s, [10.0, line + 2.0, z0 + 10.0]), "{what}: the flange ends at the leg"); let f = flat(&mut s); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{what}: {f}"); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0), "{what}: {f}"); assert!((f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) - 140.0).abs() < 1e-9, "{what}: {f}"); run(&mut s, "sheet.unfold", json!({})); assert!(rel(volume(&mut s), want) < 1e-9, "{what}: flat {} vs {want}", volume(&mut s)); } } /// A bend relief the rule makes too narrow to cut is left out, and the flange says where. #[test] fn a_bend_relief_left_out_is_reported_on_the_flange() { let mut s = Session::default(); bar_with_leg(&mut s, false); run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "relief_width": "0 mm"})); run(&mut s, "sheet.flange", json!({"edges": [[90, 30, 2]], "height": 15, "angle": 90})); let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default(); assert!(w.contains("no bend relief at (20.000, 27.000, 0.000)") && w.contains("relief width"), "{w}"); assert!(contains(&s, [10.0, 50.0, 1.0]), "the leg is kept"); } /// `fixed` inside a cut-out touches no material: the fold must refuse it or say which side it kept (#263). #[test] fn fold_fixed_point_off_the_sheet_is_not_silently_ignored() { let mut s = Session::default(); outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]])); run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "2", "name": "Window"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 15], "p1": [40, 45]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Window", "distance": "4", "direction": "negative", "operation": "cut"})); match s.execute("sheet.fold", &json!({"points": [[80, 0, 0.8], [80, 60, 0.8]], "fixed": [25, 30, 0.8]})) { Err(e) => assert!(e.to_string().contains("fixed"), "{e}"), Ok(r) => { let warned = ["warning", "warnings"].iter().any(|k| r.get(*k).is_some_and(|w| !w.is_null() && w != &json!([]) && w.to_string().contains("fixed"))); assert!(warned, "fixed [25, 30] is inside the window, yet the fold succeeded silently: {r}"); } } } /// A tab whose fold line sits exactly at its root: the bend zone reaches the blank outline beside /// the tab. Every edge is straight, so the error must not blame a slanted or curved edge (#262). #[test] fn fold_at_a_tab_root_names_the_real_cause() { let mut s = Session::default(); outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 50], [60, 50], [60, 70], [40, 70], [40, 50], [0, 50]])); if let Err(e) = s.execute("sheet.fold", &json!({"points": [[40, 50, 0.8], [60, 50, 0.8]], "fixed": [50, 25, 0.8]})) { let e = e.to_string(); assert!(!e.contains("slanted or curved"), "all edges are straight: {e}"); assert!(e.contains("bend zone"), "name the bend zone reaching the outline: {e}"); } // 1 mm into the tab the same fold works. let mut ok = Session::default(); outline_blank(&mut ok, json!([[0, 0], [100, 0], [100, 50], [60, 50], [60, 70], [40, 70], [40, 50], [0, 50]])); run(&mut ok, "sheet.fold", json!({"points": [[40, 51, 0.8], [60, 51, 0.8]], "fixed": [50, 25, 0.8]})); } /// #296: two bends meeting at an open corner relief touch only along an edge, so the folded /// material the machining guard checks must not be sewn into one solid. #[test] fn hole_beside_two_adjacent_edge_flanges_cuts_and_bends_stay_guarded() { let build = |s: &mut Session| { base(s, 80.0, 40.0); run(s, "sheet.flange", json!({"edges":[[40.0, 0.0, 2.5]],"height":30,"angle":90})); run(s, "sheet.flange", json!({"edges":[[80.0, 22.5, 2.5]],"height":15,"angle":90})); }; let hole = |s: &mut Session, name: &str, centre: [f64; 2]| { run(s, "sketch.create", json!({"plane":"XY","name":name})); run(s, "sketch.circle.center", json!({"center":centre,"radius":3})); run(s, "sketch.finish", json!({})); s.execute("solid.extrude", &json!({"sketch":name,"through_all":true,"operation":"cut"})) }; let mut s = Session::default(); build(&mut s); let before = volume(&mut s); assert!((before - 14057.2).abs() < 0.1, "{before}"); if let Err(e) = hole(&mut s, "Hole", [25.0, 20.0]) { panic!("hole beside two flanges: {e}"); } let after = volume(&mut s); assert!((before - after - 9.0 * PI * 2.5).abs() < 0.05, "{before} -> {after}"); let mut bend = Session::default(); build(&mut bend); let error = hole(&mut bend, "Bend", [78.5, 20.0]).unwrap_err().to_string(); assert!(error.contains("bend"), "{error}"); } /// The guard of #296 sews every bend on its own. A bend that carries a folded round cut-out /// (curved edges on both of its cylinders, a ruled wall between them) sews like any other: a hole /// beside it cuts, and a hole in the bend is still refused. #[test] fn hole_beside_a_bend_with_a_round_cut_out_cuts_and_the_bend_stays_guarded() { let build = |s: &mut Session| { base(s, 80.0, 40.0); run(s, "sheet.flange", json!({"edges":[[40.0, 0.0, 2.5]],"height":30,"angle":90})); run(s, "sheet.unfold", json!({})); run(s, "sketch.create", json!({"plane":"XY","name":"Across"})); run(s, "sketch.circle.center", json!({"center":[60.0, 0.0],"radius":4})); run(s, "sketch.finish", json!({})); run(s, "solid.extrude", json!({"sketch":"Across","through_all":true,"operation":"cut","direction":"symmetric"})); run(s, "sheet.refold", json!({})); }; let hole = |s: &mut Session, name: &str, centre: [f64; 2]| { run(s, "sketch.create", json!({"plane":"XY","name":name})); run(s, "sketch.circle.center", json!({"center":centre,"radius":3})); run(s, "sketch.finish", json!({})); s.execute("solid.extrude", &json!({"sketch":name,"through_all":true,"operation":"cut"})) }; let mut s = Session::default(); build(&mut s); let before = volume(&mut s); if let Err(e) = hole(&mut s, "Hole", [25.0, 20.0]) { panic!("hole beside a bend with a round cut-out: {e}"); } let after = volume(&mut s); assert!((before - after - 9.0 * PI * 2.5).abs() < 0.05, "{before} -> {after}"); let mut bend = Session::default(); build(&mut bend); let error = hole(&mut bend, "Bend", [25.0, 1.5]).unwrap_err().to_string(); assert!(error.contains("bend"), "{error}"); } /// Whether the flat pattern has a cut-out whose points all lie on the circle of radius `r` about `c`. fn has_round_cutout(f: &Value, c: [f64; 2], r: f64) -> bool { f["cutouts"].as_array().into_iter().flatten().any(|cut| { let on_circle = |p: &Value| ((p[0].as_f64().unwrap_or(f64::NAN) - c[0]).hypot(p[1].as_f64().unwrap_or(f64::NAN) - c[1]) - r).abs() < 1e-3; cut.as_array().is_some_and(|points| !points.is_empty() && points.iter().all(on_circle)) }) } /// Whether the flat DXF holds the circle of radius `r` about `c` as a `CIRCLE`. fn dxf_has_circle(s: &mut Session, c: [f64; 2], r: f64) -> bool { let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); let centre = solidcraft_geom::Vec2::new(c[0], c[1]); solidcraft_io::read_dxf(&dxf) .unwrap_or_default() .iter() .any(|g| matches!(g, solidcraft_io::Geom2::Circle(at, radius) if at.dist(centre) < 1e-5 && (radius - r).abs() < 1e-5)) } /// A hole on a flange lands in the flat pattern where the bend allowance puts its panel. #[test] fn a_hole_on_a_flange_panel_unfolds_with_the_flange() { // The flange's panel begins 5 mm (R + T) above the base's underside, and at y = 5 − BA in // the flat pattern: a hole 7 mm up the panel is at y = 5 − BA − 7 there, one 10 mm up at // y = 5 − BA − 10. let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); let mut s = Session::default(); plate_with_flange(&mut s); let v0 = volume(&mut s); // Ø4 from the flange's inner face (y = 2.5), Ø3 from its outer face (y = 0), Ø6 on the base. run(&mut s, "solid.hole", json!({"position": [30, 2.5, 12], "diameter": 4})); run(&mut s, "solid.hole", json!({"position": [70, 0, 15], "diameter": 3})); run(&mut s, "solid.hole", json!({"position": [50, 30, 2.5], "diameter": 6})); let removed = PI * 2.5 * (4.0 + 2.25 + 9.0); assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [33.0, 1.25, 12.0])); assert!(!contains(&s, [70.0, 1.25, 15.0]) && !contains(&s, [50.0, 30.0, 1.25])); let holes = [([30.0, 5.0 - ba - 7.0], 2.0), ([70.0, 5.0 - ba - 10.0], 1.5), ([50.0, 30.0], 3.0)]; let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(3)); for (centre, radius) in holes { assert!(has_round_cutout(&f, centre, radius), "{centre:?}: {}", f["cutouts"]); assert!(dxf_has_circle(&mut s, centre, radius), "{centre:?}"); } // Unfolded, the sheet is its flat pattern 2.5 thick: (55 + BA + 15) x 100, less the holes. run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - ((70.0 + ba) * 250.0 - removed)).abs() < 0.05, "{}", volume(&mut s)); run(&mut s, "sheet.refold", json!({})); assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); // A hole made on the unfolded sheet where the flange lies is on the flange once refolded. let mut flat_first = Session::default(); plate_with_flange(&mut flat_first); run(&mut flat_first, "sheet.unfold", json!({})); run(&mut flat_first, "solid.hole", json!({"position": [30, 5.0 - ba - 7.0, 2.5], "diameter": 4})); run(&mut flat_first, "sheet.refold", json!({})); assert!((v0 - volume(&mut flat_first) - PI * 4.0 * 2.5).abs() < 0.05, "{}", v0 - volume(&mut flat_first)); assert!(!contains(&flat_first, [30.0, 1.25, 12.0]) && contains(&flat_first, [33.0, 1.25, 12.0])); } /// On a folded sheet a counterbore is the same pocket around the same hole, on the base and on a /// flange, and stays through unfold and refold. #[test] fn a_counterbore_on_a_folded_sheet_is_a_pocket_around_its_cut_out() { let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5); let mut s = Session::default(); plate_with_flange(&mut s); let v0 = volume(&mut s); // Ø4 with Ø8 x 1 on the base: 22π mm³. Ø3 with Ø6 x 1 from the flange's inner face, 7 mm up // its panel: π · (1.5² · 2.5 + (3² − 1.5²) · 1) = 12.375π mm³. run(&mut s, "solid.hole", json!({"position": [80, 40, 2.5], "diameter": 4, "type": "counterbore", "cb_diameter": 8, "cb_depth": 1})); run(&mut s, "solid.hole", json!({"position": [30, 2.5, 12], "diameter": 3, "type": "counterbore", "cb_diameter": 6, "cb_depth": 1})); let removed = (22.0 + 12.375) * PI; assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); assert!(!contains(&s, [83.0, 40.0, 2.0]) && contains(&s, [83.0, 40.0, 1.0]) && !contains(&s, [80.0, 40.0, 1.0])); // The flange's pocket is in its inner face (y from 1.5 to 2.5), around the hole. assert!(!contains(&s, [32.0, 2.0, 12.0]) && contains(&s, [32.0, 1.0, 12.0]) && !contains(&s, [30.0, 1.0, 12.0])); let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(2)); assert!(has_round_cutout(&f, [80.0, 40.0], 2.0) && has_round_cutout(&f, [30.0, 5.0 - ba - 7.0], 1.5), "{}", f["cutouts"]); assert_eq!(f["pockets"].as_array().map(Vec::len), Some(2)); run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - ((70.0 + ba) * 250.0 - removed)).abs() < 0.05, "{}", volume(&mut s)); run(&mut s, "sheet.refold", json!({})); assert!((v0 - volume(&mut s) - removed).abs() < 0.05, "{}", v0 - volume(&mut s)); } /// The pocket's tool spans a cut-out inside it: extrude cuts of a pocket and of a hole in its /// floor, in either order, rebuild on the base of a folded sheet, where the pocket's own wall /// along the hole's used to make the cut fail. #[test] fn a_pocket_around_a_cut_out_rebuilds_on_a_folded_sheet() { for pocket_first in [true, false] { let mut s = Session::default(); plate_with_flange(&mut s); let v0 = volume(&mut s); let cuts = [("Pocket", 6.0, 1.0), ("Bore", 3.0, 2.5)]; for (name, diameter, depth) in if pocket_first { cuts } else { [cuts[1], cuts[0]] } { circle_sketch(&mut s, name, json!({"face": [50, 30, 2.5]}), [40.0, 30.0], diameter); run(&mut s, "solid.extrude", json!({"sketch": name, "distance": depth, "direction": "negative", "operation": "cut"})); } // π · (1.5² · 2.5 + (3² − 1.5²) · 1) = 12.375π mm³. assert!((v0 - volume(&mut s) - 12.375 * PI).abs() < 0.05, "{pocket_first}: {}", v0 - volume(&mut s)); let f = flat(&mut s); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1)); assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1)); for command in ["sheet.unfold", "sheet.refold"] { run(&mut s, command, json!({})); } assert!((v0 - volume(&mut s) - 12.375 * PI).abs() < 0.05, "{pocket_first}: {}", v0 - volume(&mut s)); } } /// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working: /// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263). #[test] fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() { let mut s = Session::default(); run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Blank"})); run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 2.5], "name": "Fold1"})); run(&mut s, "parameters.change", json!({"name": "w", "expression": "80 mm"})); let fold = run(&mut s, "timeline.inspect", json!({"feature": "Fold1"})); assert!(fold["error"].is_null(), "the fold fails after the width change: {}", fold["error"]); // The +x side (where the point is) stays flat out to the new width; the -x side stands up. let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]; assert!((bb["max"][0].as_f64().unwrap_or(0.0) - 80.0).abs() < 1e-6, "{bb}"); assert!(bb["max"][2].as_f64().unwrap_or(0.0) > 30.0, "{bb}"); } /// `fixed` 20 mm above a 2.5 mm sheet touches no material: refuse it or say so, like a point in a /// cut-out (#371). #[test] fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); match s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 20]})) { Err(e) => assert!(e.to_string().contains("fixed"), "{e}"), Ok(r) => { let warned = ["warning", "warnings"].iter().any(|k| r.get(*k).is_some_and(|w| w.to_string().contains("fixed"))); assert!(warned, "fixed [90, 30, 20] is 17.5 mm above the sheet, yet the fold succeeded silently: {r}"); } } } /// A 120 x 90 tray in 2 mm aluminium (R 1, K 0.5): four 38.6 mm flanges at `deg` in one feature. fn alu_tray(s: &mut Session, deg: f64, rule: Value) { let mut r = json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}); if let (Some(r), Some(extra)) = (r.as_object_mut(), rule.as_object()) { r.extend(extra.clone()); } run(s, "sheet.manage_rules", r); base(s, 120.0, 90.0); run(s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": format!("{deg} deg")})); } #[test] fn closed_corners_reach_the_flat_pattern_and_the_dxf() { use std::f64::consts::SQRT_2; // 60° and 105° are angles whose flat pattern was lost to a rounding slip in the planar arrangement. for deg in [60.0f64, 72.0, 90.0, 105.0] { let mut s = Session::default(); alu_tray(&mut s, deg, json!({})); assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results); // The end of the flange on y = 0 at the corner at the origin, by hand (see the doc tests): // x(w) = g/√2 + T·sinθ − w·cosθ at flat y = s0 − BA − (w − s0), for w = s0 and w = 38.6. let th = deg.to_radians(); let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0); let x_end = |w: f64| 0.5 / SQRT_2 + 2.0 * th.sin() - w * th.cos(); let f = flat(&mut s); let outline: Vec<(f64, f64)> = f["outline"][0] .as_array() .map(|l| l.iter().map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))).collect()) .unwrap_or_default(); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}"); for (x, y) in [(x_end(s0), s0 - ba), (x_end(38.6), s0 - ba - (38.6 - s0))] { assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "{deg}: ({x}, {y}) not in {outline:?}"); } // The folded part holds the flat pattern's material (K = 0.5 keeps the volume). let n = outline.len(); let area = (0..n).map(|i| outline[i].0 * outline[(i + 1) % n].1 - outline[(i + 1) % n].0 * outline[i].1).sum::().abs() / 2.0; let v = volume(&mut s); assert!((v - area * 2.0).abs() < 1e-3 * v, "{deg}: {v} vs {}", area * 2.0); // The DXF: the closed outline in straight lines, four bend lines. let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); assert_eq!(dxf.matches("0\nLINE\n").count(), n + 4, "{deg}: {n} outline points"); // Unfold and refold keep it. run(&mut s, "sheet.unfold", json!({})); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}"); // A cut made folded still goes through with the corners closed: a Ø20 hole in the base. run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true})); let want = PI * 100.0 * 2.0; assert!((v - volume(&mut s) - want).abs() < 1e-3 * want, "{deg}: {v} - {} vs {want}", volume(&mut s)); assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1), "{deg}"); } } #[test] fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() { use solidcraft_geom::Vec2; // The tray at 72°: its bends meet at the corners of the base, s0 = 3·tan 36° in from the sheet's // edges, and are BA = 2θ wide in the flat. A relief 8 mm across reaches r = 4 mm along both edges // and takes, at each corner, r²·π/4 (round) or r² (rectangular) of the base and r of two bends. let th = 72f64.to_radians(); let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0); let corners = [(s0, s0), (120.0 - s0, s0), (120.0 - s0, 90.0 - s0), (s0, 90.0 - s0)]; let export = |s: &mut Session| run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); let mut notch = Session::default(); alu_tray(&mut notch, 72.0, json!({"corner_relief": "8 mm"})); let v0 = volume(&mut notch); assert_eq!(export(&mut notch).matches("0\nARC\n").count(), 0); run(&mut notch, "sheet.unfold", json!({})); let flat0 = volume(&mut notch); for (shape, of_the_base) in [("round", 4.0 * PI), ("rectangular", 16.0)] { let mut s = Session::default(); alu_tray(&mut s, 72.0, json!({"corner_relief_shape": shape, "corner_relief": "8 mm"})); assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{shape}: {:?}", s.model.results); let listed = run(&mut s, "sheet.rules", json!({})); assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], shape); assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], shape); // The folded part loses what the flat pattern loses (K = 0.5 keeps the volume of a bend). let (v, want) = (volume(&mut s), 4.0 * (of_the_base + 8.0 * ba) * 2.0); assert!((v0 - v - want).abs() < 0.1, "{shape}: {v0} - {v} vs {want}"); // The bend table: every bend is 8 mm shorter. let f = flat(&mut s); let lengths: Vec = f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default(); for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) { assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 - 8.0)).abs() < 1e-6, "{shape}: {lengths:?}"); } assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{shape}"); // The DXF: the round reliefs are arcs of radius 4 about the four corners, the square ones lines. let dxf = export(&mut s); let arcs: Vec<(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(); if shape == "round" { assert_eq!(arcs.len(), 4, "{arcs:?}"); for (x, y) in corners { assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}"); } } else { assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}"); } // Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base. run(&mut s, "sheet.unfold", json!({})); assert!((flat0 - volume(&mut s) - want).abs() < 0.1, "{shape}: unfolded {flat0} - {} vs {want}", volume(&mut s)); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}"); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true})); assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{shape}: {v} - {}", volume(&mut s)); let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{shape}"); } } #[test] fn a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf() { use std::f64::consts::SQRT_2; // The tray at 90° and 72° (see the doc tests): its bends meet at (s0, s0), s0 = 3·tan(θ/2), and // are BA = 2θ wide in the flat. The flange on y = 0 is closed to x_end(w) = g/√2 + T·sinθ − w·cosθ. // Torn, its bend's end is cut from (s0, s0) straight to (x_end(s0), s0 − BA), where the flange's // end starts: grow = s0 − x_end(s0) = sinθ − 0.5/√2 past the corner, instead of square across the // bend and back along the flange's edge (the notch's step). for deg in [90.0f64, 72.0] { let th = deg.to_radians(); let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0); let x_end = |w: f64| 0.5 / SQRT_2 + 2.0 * th.sin() - w * th.cos(); let grow = s0 - x_end(s0); assert!((grow - (th.sin() - 0.5 / SQRT_2)).abs() < 1e-12); let points = |f: &Value| -> Vec<(f64, f64)> { let line = f["outline"][0].as_array(); line.map(|l| l.iter().map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))).collect()).unwrap_or_default() }; let mut notch = Session::default(); alu_tray(&mut notch, deg, json!({})); let (v0, stepped) = (volume(&mut notch), points(&flat(&mut notch))); run(&mut notch, "sheet.unfold", json!({})); let flat0 = volume(&mut notch); let mut s = Session::default(); alu_tray(&mut s, deg, json!({"corner_relief_shape": "tear"})); assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results); let listed = run(&mut s, "sheet.rules", json!({})); assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], "tear"); assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], "tear"); // The flat pattern, at the corner at the origin: the flange's end, the cut, the corner of the // bends, and the same on the flange on x = 0. The step is gone: a corner less at each of the // eight bend ends. let f = flat(&mut s); let outline = points(&f); let at = |pts: &[(f64, f64)], x: f64, y: f64| pts.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}"); for (x, y) in [(x_end(38.6), s0 - ba - (38.6 - s0)), (x_end(s0), s0 - ba), (s0, s0)] { assert!(at(&outline, x, y) && at(&outline, y, x), "{deg}: ({x}, {y}) not in {outline:?}"); } assert!(at(&stepped, s0, s0 - ba) && !at(&outline, s0, s0 - ba) && !at(&outline, s0 - ba, s0), "{deg}"); assert_eq!(outline.len() + 8, stepped.len(), "{deg}"); // The bend table: every centre line runs between the middles of its bend's two ends. let lengths: Vec = f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default(); for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) { assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 + grow)).abs() < 1e-6, "{deg}: {lengths:?}"); } // The folded part gains what the flat pattern gains: the triangle BA·grow/2 at each of the // eight bend ends (K = 0.5 keeps the volume of a bend). let (v, want) = (volume(&mut s), 8.0 * ba * grow / 2.0 * 2.0); assert!((v - v0 - want).abs() < 0.05, "{deg}: {v} - {v0} vs {want}"); // The DXF: the outline in straight lines, each cut one of them, and four bend lines. let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{deg}"); assert!(dxf.contains(&format!("{:.6}\n20\n{:.6}\n", x_end(s0), s0 - ba)), "{deg}"); // Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base. run(&mut s, "sheet.unfold", json!({})); assert!((volume(&mut s) - flat0 - want).abs() < 1e-6, "{deg}: unfolded {} - {flat0} vs {want}", volume(&mut s)); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}"); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true})); assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{deg}: {v} - {}", volume(&mut s)); let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{deg}"); } } #[test] fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() { let mut s = Session::default(); alu_tray(&mut s, 72.0, json!({})); let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone(); let v0 = volume(&mut s); assert_eq!(shape(&mut s), "notch"); // Only the four names are shapes; a refused one changes nothing. for wrong in [json!("star"), json!("square"), json!(""), json!(2)] { let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string(); assert!(e.contains("`corner_relief_shape` must be notch, round, rectangular or tear"), "{e}"); } assert!(shape(&mut s) == "notch" && volume(&mut s) == v0); // Changing the rule changes the parts made with it; other settings leave the shape alone. let r = run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "Round"})); assert_eq!((&r["rule"]["corner_relief_shape"], &r["values"]["corner_relief_shape"]), (&json!("round"), &json!("round"))); let round = volume(&mut s); assert!(round < v0 - 200.0, "{v0} -> {round}"); run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "hem_gap": "0.02 mm"})); assert!(shape(&mut s) == "round" && volume(&mut s) == round); run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": null, "corner_relief": "6 mm"})); assert!(shape(&mut s) == "round" && volume(&mut s) > round); for _ in 0..3 { run(&mut s, "edit.undo", json!({})); } assert!(shape(&mut s) == "notch" && volume(&mut s) == v0); // A relief longer than the bends is left out and reported on the flange; the part is the notched one. run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "rectangular", "corner_relief": "200 mm"})); let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default(); assert_eq!(w.matches("no corner relief at (").count(), 4, "{w}"); assert!(w.contains("would take a whole bend away") && volume(&mut s) == v0, "{w}"); } #[test] fn corners_left_open_are_reported_on_the_flange() { let mut s = Session::default(); alu_tray(&mut s, 72.0, json!({"gap": "0 mm"})); let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default(); assert_eq!(w.matches("left open").count(), 4, "{w}"); // Open corners still fold, unfold and take cuts. run(&mut s, "sheet.unfold", json!({})); run(&mut s, "sheet.refold", json!({})); assert!(volume(&mut s) > 0.0); } /// 2 mm aluminium (R 1, K 0.5, gap 0.5) and a 120 x 90 base flange. fn alu_base(s: &mut Session) { run( s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}), ); base(s, 120.0, 90.0); } /// The base's four edges (bottom, right, top, left) in one feature: 75° and 38.6 high from the /// feature, then 90°, 105° at 30 high and 120° at 25 high from the edges themselves. fn four_angles_in_one_feature() -> Session { let mut s = Session::default(); alu_base(&mut s); let edges = json!([ [60, 0, 2], {"point": [120, 45, 2], "angle": "90 deg"}, {"point": [60, 90, 2], "angle": 105, "height": "30 mm"}, {"point": {"x": 0, "y": 45, "z": 2}, "angle": "120 deg", "height": 25} ]); run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"})); s } /// The flat outline's points. fn outline_points(f: &Value) -> Vec<(f64, f64)> { let loops = f["outline"].as_array().cloned().unwrap_or_default(); loops .iter() .flat_map(|l| l.as_array().cloned().unwrap_or_default()) .map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN))) .collect() } /// Two point lists are the same set of points, to 1e-6. fn same_points(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { a.len() == b.len() && a.iter().all(|p| b.iter().any(|q| (p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6)) } fn bend_angles(s: &mut Session) -> Vec { flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["angle_deg"].as_f64().unwrap_or(f64::NAN).round()).collect() } fn no_errors_or_warnings(s: &Session) -> bool { s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()) } /// The flanges of [`four_angles_in_one_feature`] (bottom, right, top, left): angles in degrees /// and heights. const FOUR_ANGLES: [f64; 4] = [75.0, 90.0, 105.0, 120.0]; const FOUR_HEIGHTS: [f64; 4] = [38.6, 38.6, 30.0, 25.0]; /// That tray's panels in the flat pattern by hand (T 2, R 1, K 0.5, gap g 0.5; the derivation /// is with the doc tests). Folded, a flange at θ whose neighbour at a corner is at φ ends /// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ along its edge from /// that corner, w up its outer face from the virtual sharp. In the flat that point lies /// s0 − BA − (w − s0) into the base from the edge, with s0 = (R + T)·tan(θ/2) the setback and /// BA = θ·(R + K·T) the bend allowance; the panel spans w = s0 … the flange's height. fn hand_panels() -> Vec> { // Each edge's start corner, its direction, the direction into the base, and its length. let frames: [((f64, f64), (f64, f64), (f64, f64), f64); 4] = [ ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 120.0), ((120.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 90.0), ((120.0, 90.0), (-1.0, 0.0), (0.0, -1.0), 120.0), ((0.0, 90.0), (0.0, -1.0), (1.0, 0.0), 90.0), ]; (0..4) .map(|i| { let (o, d, v, len) = frames[i]; let (th, h) = (FOUR_ANGLES[i].to_radians(), FOUR_HEIGHTS[i]); let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0); let x_end = |phi: f64, w: f64| { let c = th.cos() * phi.cos(); (0.25 * (2.0 - 2.0 * c).sqrt() + 2.0 * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin() }; let (prev, next) = (FOUR_ANGLES[(i + 3) % 4].to_radians(), FOUR_ANGLES[(i + 1) % 4].to_radians()); let at = |u: f64, w: f64| { let into = s0 - ba - (w - s0); (o.0 + d.0 * u + v.0 * into, o.1 + d.1 * u + v.1 * into) }; vec![at(x_end(prev, s0), s0), at(x_end(prev, h), h), at(len - x_end(next, h), h), at(len - x_end(next, s0), s0)] }) .collect() } /// A polygon's area. fn polygon_area(p: &[(f64, f64)]) -> f64 { let n = p.len(); (0..n).map(|i| p[i].0 * p[(i + 1) % n].1 - p[(i + 1) % n].0 * p[i].1).sum::().abs() / 2.0 } #[test] fn edge_flanges_take_their_own_angle_and_height_per_edge() { let mut one = four_angles_in_one_feature(); assert!(no_errors_or_warnings(&one), "{:?}", one.model.results); let f1 = flat(&mut one); // The bends: each edge's own angle, as long as the base's edge between its neighbours' bend // start lines (each s0 in from the original edge). assert_eq!(bend_angles(&mut one), FOUR_ANGLES); let s0 = FOUR_ANGLES.map(|a| 3.0 * (a.to_radians() / 2.0).tan()); let ba = FOUR_ANGLES.map(|a| a.to_radians() * 2.0); let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]); let lengths: Vec = f1["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect(); assert_eq!(lengths.len(), 4, "{f1}"); for (got, want) in lengths.iter().zip([w, h, w, h]) { assert!((got - want).abs() < 1e-6, "{lengths:?} vs {w} and {h}"); } // The flat pattern is one outline through every hand-derived panel corner. let (outline, panels) = (outline_points(&f1), hand_panels()); assert_eq!(f1["outline"].as_array().map(Vec::len), Some(1), "{f1}"); for (i, panel) in panels.iter().enumerate() { for &(x, y) in panel { assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "flange {i}: ({x}, {y}) not in {outline:?}"); } } // It holds the base, the four bend zones and the four panels, nothing more or less, and its // size is the panels' extent. let area = w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels.iter().map(|p| polygon_area(p)).sum::(); assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline)); let all = panels.concat(); let span = |k: fn(&(f64, f64)) -> f64| all.iter().map(k).fold(f64::MIN, f64::max) - all.iter().map(k).fold(f64::MAX, f64::min); for (k, want) in [span(|p| p.0), span(|p| p.1), 2.0].into_iter().enumerate() { assert!((f1["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f1["flat_size_mm"]); } // Folded, the part holds that material (K = 0.5 keeps the volume). let v1 = volume(&mut one); assert!(rel(v1, area * 2.0) < 1e-3, "{v1} vs {}", area * 2.0); // The DXF: the outline in straight lines, and four bend lines. let dxf = run(&mut one, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{} outline points", outline.len()); // Unfold and refold keep it. run(&mut one, "sheet.unfold", json!({})); assert!(same_points(&outline_points(&flat(&mut one)), &outline_points(&f1))); run(&mut one, "sheet.refold", json!({})); assert!(rel(volume(&mut one), v1) < 1e-9); // Saved, an edge with its own values is an object and one without is the point it always was. let text = one.doc.to_json(); let saved: Value = serde_json::from_str(&text).unwrap(); let flange = saved["features"] .as_array() .and_then(|f| f.iter().find(|f| f["type"] == "sheet_flange" && f["edges"].as_array().is_some_and(|e| e.len() == 4))); assert_eq!( flange.map(|f| f["edges"].clone()), Some(json!([ [60.0, 0.0, 2.0], {"point": [120.0, 45.0, 2.0], "angle": "90 deg"}, {"point": [60.0, 90.0, 2.0], "angle": "105", "height": "30 mm"}, {"point": [0.0, 45.0, 2.0], "angle": "120 deg", "height": "25"} ])), "{saved}" ); // Reopened, it is the same part. let mut again = Session::new(solidcraft_doc::Document::from_json(&text).unwrap()); assert!(no_errors_or_warnings(&again), "{:?}", again.model.results); assert!(rel(volume(&mut again), v1) < 1e-9); } #[test] fn per_edge_values_are_expressions_and_feature_inputs() { let build = |angle: &str, height: &str| { let mut s = Session::default(); alu_base(&mut s); run(&mut s, "parameters.add", json!({"name": "wall_angle", "expression": "105 deg"})); run(&mut s, "parameters.add", json!({"name": "wall_height", "expression": "30 mm"})); let edges = json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "90 deg"}, {"point": [60, 90, 2], "angle": angle, "height": height}]); run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"})); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); s }; let part = |s: &mut Session| (volume(s), outline_points(&flat(s))); let mut s = build("wall_angle", "wall_height"); // The feature's inputs: its own, then each edge's, in the order of the edges. let labels: Vec = s.doc.features.last().map(|f| f.kind.inputs().into_iter().map(|(l, e, _)| format!("{l}={e}")).collect()).unwrap_or_default(); assert_eq!(labels, ["Height=38.6", "Angle=75 deg", "Edge angle=90 deg", "Edge angle=wall_angle", "Edge height=wall_height"]); // Changing the user parameters moves that flange. run(&mut s, "parameters.change", json!({"name": "wall_angle", "expression": "110 deg"})); run(&mut s, "parameters.change", json!({"name": "wall_height", "expression": "34 mm"})); let (v, outline) = part(&mut build("110 deg", "34 mm")); assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s)); assert!(same_points(&outline_points(&flat(&mut s)), &outline)); // So does the feature's own input for an edge, by its parameter name (the last two). let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default(); assert_eq!(names.len(), 5, "{names:?}"); run(&mut s, "parameters.change", json!({"name": names[3], "expression": "100 deg"})); run(&mut s, "parameters.change", json!({"name": names[4], "expression": "36 mm"})); let (v, outline) = part(&mut build("100 deg", "36 mm")); assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s)); assert!(same_points(&outline_points(&flat(&mut s)), &outline)); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); } /// The bends' lengths in the flat pattern, in the order the bends were made. fn bend_lengths(s: &mut Session) -> Vec { flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect() } /// A tray on a 120 wide, 90 deep base whose top edge is `top` wide (120: a rectangle), in 2 mm /// aluminium (R 1, K 0.5) with the flange gap `gap`: walls 20 high at `deg` in one feature and, /// in the next feature, a return 12 high at 180° − `deg` on the far edge of each of the walls /// `returns` (0…3: bottom, right, top, left; none: no feature), picked on the walls' inner faces, /// so that the returns turn in over the base and lie level. fn tray_with_returns(top: f64, deg: f64, gap: &str, returns: &[usize]) -> Session { let mut s = Session::default(); run( &mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": gap, "active": true}), ); let inset = (120.0 - top) / 2.0; let pts = [(0.0, 0.0), (120.0, 0.0), (120.0 - inset, 90.0), (inset, 90.0)]; run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.line", json!({"points": pts.map(|p| [p.0, p.1]), "closed": true})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Base"})); // A wall's inner face 20 up its outer face from the base's edge: that far in from the edge // and that high (see the doc tests). let th = deg.to_radians(); let (into, up) = (-20.0 * th.cos() + 2.0 * th.sin(), 20.0 * th.sin() + 2.0 * th.cos()); let pick = |k: usize, into: f64, z: f64| { let (a, b) = (pts[k], pts[(k + 1) % 4]); let l = (b.0 - a.0).hypot(b.1 - a.1); json!([(a.0 + b.0) / 2.0 - (b.1 - a.1) / l * into, (a.1 + b.1) / 2.0 + (b.0 - a.0) / l * into, z]) }; let edges: Vec = (0..4).map(|k| pick(k, 0.0, 2.0)).collect(); run(&mut s, "sheet.flange", json!({"edges": edges, "height": 20, "angle": format!("{deg} deg")})); if !returns.is_empty() { let edges: Vec = returns.iter().map(|&k| pick(k, into, up)).collect(); run(&mut s, "sheet.flange", json!({"edges": edges, "height": 12, "angle": format!("{} deg", 180.0 - deg)})); } s } #[test] fn returns_on_a_tray_close_at_its_corners() { use std::f64::consts::SQRT_2; // Walls at 90° with returns at 90°. The returns' feature used to fail here ("sew: nonmanifold // edge contact"): each return took its wall's bend length and ran through its neighbours. let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); // By hand (the derivation is with the doc tests; e = g/√2). The part fills 120 x 90 x 20. let e = 0.5 / SQRT_2; let b = bbox(&mut s); for (k, want) in [120.0, 90.0, 20.0].into_iter().enumerate() { let (lo, hi) = (b["min"][k].as_f64().unwrap_or(f64::NAN), b["max"][k].as_f64().unwrap_or(f64::NAN)); assert!(lo.abs() < 1e-6 && (hi - want).abs() < 1e-6, "{b}"); } // The walls' bends are the base's trimmed edges; the returns' bends stop g/√2 short of the // mitre x = y through (3, 3) at both ends. let f = flat(&mut s); let lengths = bend_lengths(&mut s); assert_eq!(lengths.len(), 8, "{f}"); for (got, want) in lengths.iter().zip([114.0, 84.0, 114.0, 84.0, 114.0 - 2.0 * e, 84.0 - 2.0 * e, 114.0 - 2.0 * e, 84.0 - 2.0 * e]) { assert!((got - want).abs() < 1e-6, "{lengths:?}"); } // The flat pattern is one outline through the corners of the return's panel on the wall on // y = 0: 9 high past two bend zones π wide and the wall's 14, mitred at 45° at both ends. let outline = outline_points(&f); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); let (y0, y1) = (-11.0 - 2.0 * PI, -20.0 - 2.0 * PI); for (x, y) in [(3.0 + e, y0), (117.0 - e, y0), (108.0 - e, y1), (12.0 + e, y1)] { assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "({x}, {y}) not in {outline:?}"); } // It holds the base, the walls' bends and panels and the returns' bends and panels, nothing // more or less, and is 20 + 2π wider than the base on every side. let area = 114.0 * 84.0 + 396.0 * PI + 28.0 * (202.0 - 4.0 * e) + 2.0 * PI * (198.0 - 4.0 * e) + 18.0 * (180.0 - 4.0 * e); assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline)); for (k, want) in [160.0 + 4.0 * PI, 130.0 + 4.0 * PI, 2.0].into_iter().enumerate() { assert!((f["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f["flat_size_mm"]); } // Folded, the part holds that material (K = 0.5 keeps the volume). let v = volume(&mut s); assert!(rel(v, area * 2.0) < 1e-3, "{v} vs {}", area * 2.0); // The DXF: the outline in straight lines, and eight bend lines. let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 8, "{} outline points", outline.len()); // Unfold and refold keep it. run(&mut s, "sheet.unfold", json!({})); assert!(same_points(&outline_points(&flat(&mut s)), &outline)); run(&mut s, "sheet.refold", json!({})); assert!(rel(volume(&mut s), v) < 1e-9); // Reopened, it is the same part. let mut again = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!(no_errors_or_warnings(&again), "{:?}", again.model.results); assert!(rel(volume(&mut again), v) < 1e-9); assert!(same_points(&outline_points(&flat(&mut again)), &outline)); } #[test] fn returns_on_leaning_walls_stay_inside_the_walls() { // A trapezoid (corners of 77.5° and 102.5°) with walls at 110° and level returns at 70°. Each // return used to run its wall's bend length from where the wall's far edge started, out // through the neighbouring wall: the part reached x = 120.588 where its walls end at 117.587. let mut s = tray_with_returns(80.0, 110.0, "0.5 mm", &[0, 1, 2, 3]); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); // The returns lie inside the walls and no higher than their tips: the part's box is the // walls' own. let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(80.0, 110.0, "0.5 mm", &[]))); for end in ["min", "max"] { for k in 0..3 { let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN)); assert!((a - b).abs() < 1e-6, "{with} vs {without}"); } } // By hand (the doc tests): a return's panel starts v = 3·sin 110° + (20 − 3·tan 35°)·cos 70° // in from its wall's edge, and at a base corner of angle α its bend stops // x_b = (g/2 + v·cos(α/2)) / sin(α/2) along the edge from the corner. let th = 110f64.to_radians(); let v = 3.0 * th.sin() - (20.0 - 3.0 * 35f64.to_radians().tan()) * th.cos(); let x_b = |alpha: f64| (0.25 + v * (alpha / 2.0).cos()) / (alpha / 2.0).sin(); let (acute, side) = ((90.0f64 / 20.0).atan(), 20f64.hypot(90.0)); let want = [120.0 - 2.0 * x_b(acute), side - x_b(acute) - x_b(PI - acute), 80.0 - 2.0 * x_b(PI - acute), side - x_b(acute) - x_b(PI - acute)]; let lengths = bend_lengths(&mut s); assert_eq!(lengths.len(), 8, "{lengths:?}"); for (got, want) in lengths[4..].iter().zip(want) { assert!((got - want).abs() < 1e-6, "{lengths:?} vs {want:?}"); } // One outline in the flat, and the folded part holds its material. let f = flat(&mut s); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); let (outline, vol) = (outline_points(&f), volume(&mut s)); assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0); run(&mut s, "sheet.unfold", json!({})); assert!(same_points(&outline_points(&flat(&mut s)), &outline)); run(&mut s, "sheet.refold", json!({})); assert!(rel(volume(&mut s), vol) < 1e-9); } #[test] fn a_return_alone_on_a_leaning_wall_keeps_out_of_the_walls_beside_it() { use std::f64::consts::SQRT_2; // The tray with walls at 110° and a level return on the wall on y = 0 only. It used to take // that wall's bend length (120 − 2·3·tan 55° = 111.431) from where the wall's far edge starts // and came out through the wall on x = 120: the part reached x = 120.504 where its walls end // at 118.716. let mut s = tray_with_returns(120.0, 110.0, "0.5 mm", &[0]); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(120.0, 110.0, "0.5 mm", &[]))); for end in ["min", "max"] { for k in 0..3 { let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN)); assert!((a - b).abs() < 1e-6, "{with} vs {without}"); } } // By hand (the doc tests): the walls beside it lean in over it and come nearest at its top, // 20·sin 110° up, where their inner faces are 2/sin 110° + 20·cos 70° in from the base's edges. // The return stops square g/√2 short of them at both ends: 120 − 2·9.322312 = 101.355376. let th = 110f64.to_radians(); let want = 120.0 - 2.0 * (0.5 / SQRT_2 + 2.0 / th.sin() - 20.0 * th.cos()); let lengths = bend_lengths(&mut s); assert!(lengths.len() == 5 && (lengths[4] - want).abs() < 1e-6 && (want - 101.355376).abs() < 1e-6, "{lengths:?} vs {want}"); // One outline in the flat, and the folded part holds its material. let f = flat(&mut s); assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); let (outline, vol) = (outline_points(&f), volume(&mut s)); assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0); } #[test] fn returns_follow_the_rules_gap_and_stay_open_with_a_warning_when_it_is_zero() { use std::f64::consts::SQRT_2; // The mitre keeps the rule's gap: at 90° the returns' bends are 114 − 2·g/√2 and 84 − 2·g/√2. let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]); for g in [0.5, 1.0, 0.2] { run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "gap": format!("{g} mm")})); assert!(no_errors_or_warnings(&s), "{g}: {:?}", s.model.results); let lengths = bend_lengths(&mut s); assert_eq!(lengths.len(), 8, "{g}: {lengths:?}"); for (got, want) in lengths[4..].iter().zip([114.0, 84.0, 114.0, 84.0]) { assert!((got - (want - 2.0 * g / SQRT_2)).abs() < 1e-6, "{g}: {lengths:?}"); } } // A gap of zero can't be kept: the walls' corners and the returns' corners are left open, // each feature with its warning, and each return spans its wall's far edge (here the wall's // bend, 120 − 2·3·tan 55°). let mut s = tray_with_returns(120.0, 110.0, "0 mm", &[0, 1, 2, 3]); let warnings: Vec = s.model.results.iter().filter_map(|r| r.warning.clone()).collect(); assert!(s.model.results.iter().all(|r| r.error.is_none()) && warnings.len() == 2, "{:?}", s.model.results); assert!(warnings.iter().all(|w| w.matches("left open").count() == 4), "{warnings:?}"); let lengths = bend_lengths(&mut s); assert!((lengths[4] - lengths[0]).abs() < 1e-9 && (lengths[0] - (120.0 - 6.0 * 55f64.to_radians().tan())).abs() < 1e-6, "{lengths:?}"); } #[test] fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() { let mut s = Session::default(); alu_base(&mut s); let err = |s: &mut Session, edges: Value| { s.execute("sheet.flange", &json!({"edges": edges, "height": 38.6, "angle": "90 deg"})).err().map(|e| e.to_string()).unwrap_or_default() }; // The wrong kind of value, or no expression at all: the edge and the key are named. let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "10 mm"}])); assert!(e.contains("edges[1].angle"), "{e}"); let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "30 deg"}])); assert!(e.contains("edges[0].height"), "{e}"); let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": "nope"}])); assert!(e.contains("edges[0].angle"), "{e}"); let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": [1, 2]}])); assert!(e.contains("edges[0]") && e.contains("`angle` must be a number or an expression"), "{e}"); // An unknown key, a point that is none, an angle without `point`, something that is no edge. let e = err(&mut s, json!([{"point": [60, 0, 2], "twist": 3}])); assert!(e.contains("edges[0]") && e.contains("unknown key `twist`"), "{e}"); let e = err(&mut s, json!([{"point": [60, 0], "angle": "60 deg"}])); assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}"); let e = err(&mut s, json!([{"x": 60, "y": 0, "z": 2, "angle": "60 deg"}])); assert!(e.contains("edges[0]") && e.contains("unknown key"), "{e}"); let e = err(&mut s, json!([{"angle": "60 deg"}])); assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}"); let e = err(&mut s, json!(["edge"])); assert!(e.contains("edges[0]"), "{e}"); let e = err(&mut s, json!([])); assert!(e.contains("1…1000"), "{e}"); // Evaluated: an angle outside 0…180° and a height under the bend's, each naming its edge. let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "200 deg"}])); assert!(e.contains("edge 2") && e.contains("angle must be between 0 and 180"), "{e}"); let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "2 mm"}, [120, 45, 2]])); assert!(e.contains("edge 1") && e.contains("height must be at least"), "{e}"); // None of it added a feature. assert_eq!(s.doc.features.len(), 2, "{:?}", s.doc.features.iter().map(|f| f.name.clone()).collect::>()); // Plain points, `{x, y, z}` points and `{point}` without values are saved as the points they were. run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], {"x": 120, "y": 45, "z": 2}, {"point": [60, 90, 2]}], "height": 20, "angle": "90 deg"})); let edges = serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap()["edges"].clone(); assert_eq!(edges, json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0]])); } #[test] fn editing_a_flange_keeps_each_edges_own_values_with_its_edge() { let mut s = four_angles_in_one_feature(); let id = s.doc.features.last().map(|f| f.id).unwrap_or(0); // Give the first edge 80° and take the 90° off the second. let edges = json!([ {"point": [60, 0, 2], "angle": "80 deg"}, [120, 45, 2], {"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"}, {"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"} ]); run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}})); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); assert_eq!(bend_angles(&mut s), [80.0, 75.0, 105.0, 120.0]); let kind = |s: &Session| serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap(); assert_eq!(kind(&s)["edges"][1], json!([120.0, 45.0, 2.0]), "no value of its own: a plain point again"); let (v, outline) = (volume(&mut s), outline_points(&flat(&mut s))); // The same edges in another order are the same part: each value follows its edge. let edges = json!([ {"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"}, {"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"}, {"point": [60, 0, 2], "angle": "80 deg"}, [120, 45, 2] ]); run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}})); assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]); assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s)); assert!(same_points(&outline_points(&flat(&mut s)), &outline)); // A bad edit is refused and leaves the feature as it was. let before = kind(&s); assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "80 deg", "colour": 1}]}})).is_err()); assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "250 deg"}]}})).is_err()); assert_eq!(kind(&s), before); assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]); } #[test] fn designs_without_per_edge_values_are_read_and_written_as_before() { let mut s = Session::default(); alu_base(&mut s); run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": "72 deg"})); let v = volume(&mut s); let text = s.doc.to_json(); let mut design: Value = serde_json::from_str(&text).unwrap(); let flange = design["features"].as_array().and_then(|f| f.iter().position(|f| f["type"] == "sheet_flange")).unwrap(); assert_eq!(design["features"][flange]["edges"], json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0], [0.0, 45.0, 2.0]])); let mut same = Session::new(solidcraft_doc::Document::from_json(&text).unwrap()); assert!(rel(volume(&mut same), v) < 1e-12); // A design file whose last edge carries its own angle is the part the command makes. design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "angle": "100 deg"}); let mut opened = Session::new(solidcraft_doc::Document::from_json(&design.to_string()).unwrap()); assert!(no_errors_or_warnings(&opened), "{:?}", opened.model.results); let mut made = Session::default(); alu_base(&mut made); let edges = json!([[60, 0, 2], [120, 45, 2], [60, 90, 2], {"point": [0, 45, 2], "angle": "100 deg"}]); run(&mut made, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "72 deg"})); let (vo, vm) = (volume(&mut opened), volume(&mut made)); assert!(rel(vo, vm) < 1e-9 && rel(vo, v) > 1e-4, "{vo} vs {vm} vs {v}"); // A key this build doesn't know is refused, not dropped. design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "twist": "3 deg"}); assert!(solidcraft_doc::Document::from_json(&design.to_string()).is_err()); } /// Names of the hole's side faces (curved, smaller than the bend faces) in `Body1`. fn hole_face_names(s: &mut Session) -> Vec { let faces = run(s, "model.faces", json!({"body": "Body1"})); let faces = faces["faces"].as_array().cloned().unwrap_or_default(); faces .iter() .filter(|f| f["plane_normal"].is_null() && f["area"].as_f64().is_some_and(|a| a < 100.0)) .map(|f| f["name"].as_str().unwrap_or_default().to_string()) .collect() } #[test] fn a_hole_on_a_folded_panel_keeps_the_name_of_its_sketch_circle() { // A Ø10 hole at x = 20 on a 100 x 60 plate; the fold at x = 40 stands that (smaller) side up. let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); let circle = run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": 5})); let curve = circle["curves"][0].as_str().unwrap_or("c1").to_string(); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"})); let side = format!(":side:{curve}#"); let flat = hole_face_names(&mut s); assert_eq!(flat.len(), 2, "{flat:?}"); assert!(flat.iter().all(|n| n.contains(&side)), "before the fold: {flat:?}"); fold_line(&mut s); run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"})); assert!(bbox(&mut s)["max"][2].as_f64().unwrap_or(0.0) > 20.0, "the hole's panel stands up"); let folded = hole_face_names(&mut s); assert_eq!(folded.len(), 2, "{folded:?}"); assert!(folded.iter().all(|n| n.contains(&side)), "after the fold the hole is still circle {curve}: {folded:?} (before: {flat:?})"); } #[test] fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() { // A 10 x 10 box perpendicular to the sheet and through its whole thickness removes exactly // what a sketch rectangle extruded through all removes, which is accepted today. let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "solid.box", json!({"corner": [20, 20, -1], "length": 10, "width": 10, "height": 5, "operation": "cut"})); let mut extruded = Session::default(); base(&mut extruded, 100.0, 60.0); rectangle_tool(&mut extruded, "Hole", [20.0, 20.0], [30.0, 30.0]); run(&mut extruded, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"})); for session in [&mut s, &mut extruded] { assert!((volume(session) - 14750.0).abs() < 1e-6); assert_eq!(flat(session)["cutouts"].as_array().map(Vec::len), Some(1)); } run(&mut s, "sheet.unfold", json!({})); run(&mut s, "sheet.refold", json!({})); assert!((volume(&mut s) - 14750.0).abs() < 1e-6); // A cylinder along the sheet normal is a round hole, exact in the flat pattern and DXF. run(&mut s, "solid.cylinder", json!({"base": [70, 30, -1], "radius": 5, "height": 5, "operation": "cut"})); assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2)); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); assert!(dxf.contains("CIRCLE"), "the hole stays a circle"); assert!((volume(&mut s) - (14750.0 - PI * 25.0 * 2.5)).abs() < 0.5); // A box that only dents the top face is a pocket of constant depth. run(&mut s, "solid.box", json!({"corner": [50, 10, 1.5], "length": 10, "width": 10, "height": 5, "operation": "cut"})); let f = flat(&mut s); assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1)); assert_eq!(f["pockets"][0]["depth_mm"], 1.0); // A box across a folded bend, or a cylinder lying in the sheet, is still 3D machining // and still refused atomically. let mut tilted = Session::default(); base(&mut tilted, 100.0, 60.0); run(&mut tilted, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); let before = tilted.doc.to_json(); let error = tilted .execute("solid.box", &json!({"corner": [40, -10, -5], "length": 20, "width": 20, "height": 40, "operation": "cut"})) .unwrap_err() .to_string(); assert!(error.contains("bend") || error.contains("preserv"), "{error}"); assert_eq!(tilted.doc.to_json(), before); let error = s .execute("solid.cylinder", &json!({"base": [10, 50, 1.25], "axis": "X", "radius": 1, "height": 20, "operation": "cut"})) .unwrap_err() .to_string(); assert!(error.contains("preserv"), "{error}"); } #[test] fn flat_pattern_reports_a_round_hole_as_a_circle() { // The DXF export already writes this hole as a CIRCLE; the JSON flat pattern should say // "circle, centre (50, 30), radius 1.6" too, not only a sampled polyline: any object in the // cut-out's shape that has a `center` and a `radius` counts. fn circles(v: &Value, out: &mut Vec<([f64; 2], f64)>) { match v { Value::Object(m) => { let center = m.get("center").and_then(Value::as_array).and_then(|c| Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?])); if let (Some(center), Some(radius)) = (center, m.get("radius").and_then(Value::as_f64)) { out.push((center, radius)); } m.values().for_each(|v| circles(v, out)); } Value::Array(a) => a.iter().for_each(|v| circles(v, out)), _ => {} } } let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 1.6})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"})); let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); assert!(dxf.contains("CIRCLE"), "the DXF keeps the circle"); let f = flat(&mut s); let cutouts = f["cutouts"].as_array().cloned().unwrap_or_default(); assert_eq!(cutouts.len(), 1); // The point list stays for existing readers; `cutout_shapes` carries the exact shape. assert!(cutouts[0].as_array().is_some_and(|p| p.len() > 8)); let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default(); assert_eq!(shapes.len(), 1); let mut found = Vec::new(); circles(&shapes[0], &mut found); let shown: String = shapes[0].to_string().chars().take(120).collect(); assert!( found.iter().any(|(c, r)| (c[0] - 50.0).abs() < 1e-9 && (c[1] - 30.0).abs() < 1e-9 && (r - 1.6).abs() < 1e-9), "the round hole is not reported as a circle: {shown} ..." ); // A slot is its two lines and two half-circle arcs, exactly. run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"})); run(&mut s, "sketch.slot.overall", json!({"p0": [10, 10], "p1": [30, 10], "width": 4})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); let f = flat(&mut s); let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default(); assert_eq!(shapes.len(), f["cutouts"].as_array().map_or(0, Vec::len)); let slot = shapes.iter().find(|s| s.get("segments").is_some()).cloned().unwrap_or_default(); let segments = slot["segments"].as_array().cloned().unwrap_or_default(); assert_eq!(segments.iter().filter(|s| s.get("line").is_some()).count(), 2, "{slot}"); let arcs: Vec<&Value> = segments.iter().filter_map(|s| s.get("arc")).collect(); assert_eq!(arcs.len(), 2, "{slot}"); assert!( arcs.iter() .all(|a| (a["radius"].as_f64().unwrap_or(0.0) - 2.0).abs() < 1e-9 && (a["sweep_deg"].as_f64().unwrap_or(0.0).abs() - 180.0).abs() < 1e-6) ); } #[test] fn a_flange_asked_for_0_8_mm_is_0_8_mm_or_refused_never_silently_2_5_mm() { // `thickness` on sheet.flange may become an inline override of the rule, or be refused as // an unknown parameter; either is fine. Building the default 2.5 mm sheet silently is not. let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.finish", json!({})); match s.execute("sheet.flange", &json!({"sketch": "Base", "thickness": 0.8})) { Ok(_) => { assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(f64::NAN) - 0.8).abs() < 1e-9); assert!((volume(&mut s) - 100.0 * 60.0 * 0.8).abs() < 1e-6, "built at {} mm3", volume(&mut s)); } Err(e) => assert!(e.to_string().contains("thickness"), "{e}"), } } /// A new fold still refuses a `fixed` point in a cut-out or past the outline, leaving the design /// untouched (#263); a stored one that drifts off the sheet keeps its side with a warning (#370). #[test] fn fold_refuses_a_new_fixed_point_off_the_sheet_but_warns_on_recompute() { let mut s = Session::default(); run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Blank"})); rectangle_tool(&mut s, "Window", [10.0, 15.0], [30.0, 45.0]); run(&mut s, "solid.extrude", json!({"sketch": "Window", "through_all": true, "direction": "symmetric", "operation": "cut"})); let before = s.doc.to_json(); let undo = s.undo.len(); for fixed in [json!([20, 30, 2.5]), json!([150, 30, 2.5])] { let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string(); assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}"); assert_eq!(s.doc.to_json(), before); assert_eq!(s.undo.len(), undo); } run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 2.5], "name": "Fold1"})); assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null()); run(&mut s, "parameters.change", json!({"name": "w", "expression": "80 mm"})); let fold = run(&mut s, "timeline.inspect", json!({"feature": "Fold1"})); assert!(fold["error"].is_null(), "{fold}"); assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet") && w.contains("stays")), "{fold}"); } /// Points away from the sheet are refused atomically; points on any face of it, on a flange's /// leg, or a hair off a face still pick the side that stays (#371). #[test] fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let before = s.doc.to_json(); let undo = s.undo.len(); for fixed in [json!([90, 30, 20]), json!([90, 30, -10])] { let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string(); assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}"); assert_eq!(s.doc.to_json(), before); assert_eq!(s.undo.len(), undo); } // The left (smaller) side stays: the right part stands up. for fixed in [json!([10, 30, 2.5]), json!([10, 30, 0]), json!([0, 30, 1.25]), json!([10, 30, 2.500001])] { let mut s = Session::default(); base(&mut s, 100.0, 60.0); run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed, "name": "Fold1"})); let bb = bbox(&mut s); assert!(bb["min"][0].as_f64().unwrap_or(f64::NAN).abs() < 1e-6 && bb["max"][0].as_f64().unwrap_or(f64::NAN) < 50.0, "{fixed}: {bb}"); assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null(), "{fixed}"); } // A point on an edge flange's leg (far above the base panel's slab) is on the sheet. let mut f = Session::default(); run(&mut f, "parameters.change", json!({"name": "h", "expression": "20 mm"})); base(&mut f, 100.0, 60.0); run(&mut f, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": "h"})); let bb = bbox(&mut f); let x = bb["max"][0].as_f64().unwrap_or(f64::NAN); run(&mut f, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [x, 30, 15], "name": "Fold1"})); assert!(run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null()); // A shorter leg leaves the stored point in the air: the fold recomputes, keeps its side and warns. run(&mut f, "parameters.change", json!({"name": "h", "expression": "10 mm"})); let fold = run(&mut f, "timeline.inspect", json!({"feature": "Fold1"})); assert!(fold["error"].is_null(), "{fold}"); assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}"); assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays"); } /// The middle of the largest triangle of each flat face of the first body: well inside it. fn flat_face_points(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3, solidcraft_geom::Vec3)> { let state = s.model.state(); let body = &state.bodies[0].body; let mesh = body.tessellate(0.01).unwrap(); (0..body.face_count()) .filter_map(|index| { let normal = body.planar_face(index)?.normal(); let point = mesh .triangles .iter() .zip(&mesh.tri_face) .filter(|(_, f)| **f as usize == index) .filter_map(|(t, _)| mesh.tri(t)) .max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len())) .map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?; Some((index, point, normal)) }) .collect() } /// Every flat face of a sheet takes an attached sketch (#495): its panels, the rims along the /// panels' straight edges (named by the base sketch line or the flange's far edge and sides) and /// the flat ends of a bend. Each lies on its face, facing out of the sheet, and a rim's sketch /// follows the rim when the base grows. A press-pull on a rim pushes it out, and a plain hole into /// a panel is kept as a cut-out of the flat pattern (#560). #[test] fn every_flat_face_of_a_sheet_takes_an_attached_sketch() { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name": "W", "expression": "100 mm"})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]})); run(&mut s, "sketch.constraint.fix", json!({"entities": ["p1"]})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "sheet.flange", json!({"sketch": "Base"})); run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})); let faces = flat_face_points(&s); // The base: its two faces and three rims (the fourth edge bends); the flange: its two faces, // its far rim and two sides; the two ends of the bend. assert_eq!(faces.len(), 12); let mut on_side = None; for (index, point, normal) in faces { let made = run(&mut s, "sketch.create", json!({"plane": {"face": point, "body": "Body1", "index": index}})); run(&mut s, "sketch.finish", json!({})); assert_eq!(made["attachment"], "sheet_face", "face {index} at {point:?}"); let id = made["sketch"].as_u64().unwrap(); let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(id, &s.model) else { panic!("face {index} at {point:?}") }; assert!(plane.normal().dot(normal) > 1.0 - 1e-9 && (point - plane.origin).dot(normal).abs() < 1e-6, "face {index}: {plane:?}"); if (point.x - 100.0).abs() < 1e-6 && point.y > 10.0 { on_side = Some(id); } } // The base's rim along x = W (the sketch's l2) moves with W. let side = on_side.expect("the base rim on x = 100"); run(&mut s, "parameters.change", json!({"name": "W", "expression": "120 mm"})); let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(side, &s.model) else { panic!("the rim sketch after the edit") }; assert!((plane.origin.x - 120.0).abs() < 1e-9 && (plane.normal().x - 1.0).abs() < 1e-9, "{plane:?}"); // Press-pull the rim 5 mm out: the base grows by 5 x 60 x 2.5 less what the flange covers. let v0 = volume(&mut s); run(&mut s, "solid.press_pull", json!({"face": [120, 30, 1.25], "distance": 5})); assert!(volume(&mut s) > v0 + 5.0 * 50.0 * 2.5, "{} {v0}", volume(&mut s)); // A plain hole into the base is a cut-out of the flat pattern. let mut t = Session::default(); plate_with_flange(&mut t); let v0 = volume(&mut t); run(&mut t, "solid.hole", json!({"position": [50, 30, 2.5], "direction": [0, 0, -1], "diameter": 6})); assert!((v0 - volume(&mut t) - PI * 9.0 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut t)); let f = flat(&mut t); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}"); } /// A 60 x 40 base with a 30 mm flange up its far long edge (y = 40); its thickness. fn l_bracket(s: &mut Session) -> f64 { base(s, 60.0, 40.0); run(s, "sheet.flange", json!({"type": "edge", "edges": [[30, 40, 0]], "height": 30})); flat(s)["thickness"].as_f64().unwrap() } #[test] fn a_hole_drilled_into_a_rim_is_a_bore_the_sheet_keeps_when_it_unfolds() { let mut s = Session::default(); let t = l_bracket(&mut s); let before = volume(&mut s); let flat_before = unfolded_volume(&mut s); let (d, y) = (t * 0.6, 20.0); // Into the base's end rim (x = 60) along the base, through all: out at x = 0. run(&mut s, "solid.hole", json!({"position": [60, y, t / 2.0], "direction": [-1, 0, 0], "diameter": d})); let removed = PI * (d / 2.0).powi(2) * 60.0; let folded = volume(&mut s); assert!(rel(before - folded, removed) < 0.02, "removed {} of {removed}", before - folded); let f = flat(&mut s); assert_eq!(f["bores"].as_array().map(Vec::len), Some(1), "{f}"); assert_eq!(f["cutouts"], json!([])); let inside = |s: &Session, p: [f64; 3]| s.model.state().bodies[0].body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])); // The flat pattern loses the same bore (the bends unroll to their own length). let flat_after = unfolded_volume(&mut s); assert!(rel(flat_before - flat_after, removed) < 0.02, "flat: removed {} of {removed}", flat_before - flat_after); assert!(rel(volume(&mut s), folded) < 1e-6); // Folded again: the bore is where it was drilled, the material beside it stays. assert!(!inside(&s, [30.0, y, t / 2.0])); assert!(inside(&s, [30.0, y + d, t / 2.0])); let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); assert!(rel(volume(&mut reopened), folded) < 1e-3); assert!(rel(unfolded_volume(&mut reopened), flat_after) < 1e-6); } /// The volume of the sheet unfolded; it is folded back after. fn unfolded_volume(s: &mut Session) -> f64 { run(s, "sheet.unfold", json!({})); let v = volume(s); run(s, "sheet.refold", json!({})); v } #[test] fn a_rim_hole_in_a_flange_follows_it_into_the_flat_pattern() { let mut s = Session::default(); let t = l_bracket(&mut s); let before = volume(&mut s); let flat_before = unfolded_volume(&mut s); let inside = |s: &Session, p: [f64; 3]| s.model.state().bodies[0].body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])); // Halfway up the flange, in the middle of its thickness (whichever way the rule put it). let mid = [[40.0 - t / 2.0, 15.0], [40.0 - t / 2.0, -15.0], [40.0 + t / 2.0, 15.0], [40.0 + t / 2.0, -15.0]] .into_iter() .find(|[y, z]| inside(&s, [30.0, *y, *z])) .expect("the flange's material"); let d = t * 0.5; // Into the flange's end rim (x = 60), through all. run(&mut s, "solid.hole", json!({"position": [60.0, mid[0], mid[1]], "direction": [-1, 0, 0], "diameter": d})); let folded = volume(&mut s); assert!(rel(before - folded, PI * (d / 2.0).powi(2) * 60.0) < 0.02, "{before} -> {folded}"); assert!(!inside(&s, [30.0, mid[0], mid[1]])); let flat_after = unfolded_volume(&mut s); assert!(rel(flat_before - flat_after, before - folded) < 0.02, "flat: {flat_before} -> {flat_after}"); assert!(rel(volume(&mut s), folded) < 1e-6); assert!(!inside(&s, [30.0, mid[0], mid[1]])); } #[test] fn a_rim_hole_that_would_run_into_a_bend_is_refused_and_changes_nothing() { let mut s = Session::default(); let t = l_bracket(&mut s); let before = volume(&mut s); // Into the near rim (y = 0) toward the bend at y = 40. let r = s.execute("solid.hole", &json!({"position": [30, 0, t / 2.0], "direction": [0, 1, 0], "diameter": t * 0.6})); assert!(r.is_err(), "{r:?}"); assert!(rel(volume(&mut s), before) < 1e-9); assert!(flat(&mut s).get("bores").is_none()); } /// #732: a perforated panel. A 400 x 470 x 0.8 sheet with Ø1 holes on a 1.5 mm 60° staggered grid (DIN 24041 /// Rv 1.0-1.5), cut in two sketches of 500 holes; 0.8.0 refuses the second cut ("sheet metal flat geometry exceeds /// the bounded processing budget"). A real mesh panel needs ~94 000 such holes; 1000 is the first step. #[test] fn perforated_sheet_takes_a_thousand_round_holes() { let mut s = Session::default(); outline_blank(&mut s, json!([[0, 0], [400, 0], [400, 470], [0, 470]])); let flat = volume(&mut s); let (p, dy) = (1.5_f64, 1.5_f64 * 3f64.sqrt() / 2.0); let mut holes = Vec::new(); 'grid: for r in 0..(450.0 / dy) as usize { for k in 0..(380.0 / p) as usize { let x = 10.0 + k as f64 * p + if r % 2 == 1 { p / 2.0 } else { 0.0 }; holes.push([x, 10.0 + r as f64 * dy]); if holes.len() == 1000 { break 'grid; } } } for (g, chunk) in holes.chunks(500).enumerate() { let name = format!("Perforation {g}"); run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 2], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": name})); for c in chunk { run(&mut s, "sketch.circle.center", json!({"center": c, "radius": 0.5})); } run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": name, "distance": "4", "direction": "negative", "operation": "cut"})); } let removed = flat - volume(&mut s); let expected = 1000.0 * std::f64::consts::PI * 0.5 * 0.5 * 0.8; assert!((removed - expected).abs() / expected < 0.05, "removed {removed:.1} mm³, 1000 holes are {expected:.1} mm³"); }