From d5ff5ea1a574658412bb0de5c5a28f6265ea1cff Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 00:40:08 -0400 Subject: [PATCH] Sheet metal: a thousand cut-outs fit the flat budget, ten times faster (#732) (#964) - A new cut was clipped against the panel with every one of its cut-outs, so each hole of a perforation cost the square of all the holes before it and the second cut of 500 ran out of the flat budget. A tool is now clipped against only the cut-outs whose boxes meet its own (the same result). - The planar booleans' point-in-loop test now skips loops whose box is clear of the point, before the costly on-the-loop test; with a thousand holes that test was most of the time. The 1000-hole panel now takes about 15 s instead of failing. Co-authored-by: jonaswre <3731681+jonaswre@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/doc/src/eval_more.rs | 6 +- crates/doc/src/sheet.rs | 40 + crates/engine/src/cmd/sheet_tests.rs | 33 + crates/engine/src/cmd/sheet_tests.rs.orig | 3550 +++++++++++++++++++++ crates/kernel/src/prism.rs | 26 + 5 files changed, 3654 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/sheet_tests.rs.orig diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 10c5d457..65aa9c8f 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -968,9 +968,13 @@ fn sheet_prism_cut( } modeled = true; } else if z1 > z0 { + // Each tool meets only the panel's cut-outs near it: arranging every one of a + // perforation's holes against every new hole would grow with their square (#732). + let near = sheet::NearHoles::new(&panel)?; for flat in mapped { + let local = near.around(&flat)?; let clipped = - sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; + sheet::material_boolean(std::slice::from_ref(&local), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; if !clipped.is_empty() && !depth_resolved { return Err(DocError::Invalid( "sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(), diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index a16b40db..6ec2370d 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -166,6 +166,46 @@ fn regions_complexity<'a>(regions: impl Iterator) -> Result< Ok((edges, points)) } +/// A flat region's cut-outs by their bounding boxes, to clip tools against only the ones near +/// them (the result is the same: a cut-out clear of a tool's box can't change what it meets). +pub(crate) struct NearHoles<'a> { + region: &'a Region2, + boxes: Vec<(Vec2, Vec2)>, +} + +/// A loop's bounding box, a little larger than its chords so arcs stay inside. +fn loop_box(lp: &Loop2) -> Result<(Vec2, Vec2)> { + let pts = flat_polyline(lp)?; + let (lo, hi) = pts.iter().fold((Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)), |(lo, hi), p| { + (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) + }); + let pad = 1e-3 + 1e-9 * (lo.x.abs() + lo.y.abs() + hi.x.abs() + hi.y.abs()); + Ok((lo - Vec2::new(pad, pad), hi + Vec2::new(pad, pad))) +} + +impl<'a> NearHoles<'a> { + pub(crate) fn new(region: &'a Region2) -> Result { + Ok(Self { region, boxes: region.holes.iter().map(loop_box).collect::>()? }) + } + + /// The region with only the cut-outs whose boxes meet `tool`'s. + pub(crate) fn around(&self, tool: &Region2) -> Result { + if self.region.holes.is_empty() { + return Ok(self.region.clone()); + } + let (lo, hi) = loop_box(&tool.outer)?; + let holes = self + .region + .holes + .iter() + .zip(&self.boxes) + .filter(|(_, (a, b))| a.x <= hi.x && b.x >= lo.x && a.y <= hi.y && b.y >= lo.y) + .map(|(h, _)| h.clone()) + .collect(); + Ok(Region2 { outer: self.region.outer.clone(), holes }) + } +} + pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { let (ae, ap) = region_complexity(a)?; let (be, bp) = region_complexity(b)?; diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 4c00dfae..9dcc80a9 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -3570,3 +3570,36 @@ fn every_flat_face_of_a_sheet_takes_an_attached_sketch() { let f = flat(&mut t); assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}"); } + +/// #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³"); +} diff --git a/crates/engine/src/cmd/sheet_tests.rs.orig b/crates/engine/src/cmd/sheet_tests.rs.orig new file mode 100644 index 00000000..9f1178e7 --- /dev/null +++ b/crates/engine/src/cmd/sheet_tests.rs.orig @@ -0,0 +1,3550 @@ +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)); + let mut t = Session::default(); + run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"})); + assert!(t.execute("sheet.convert", &json!({"body": "Rod", "face": [0, 0, 30]})).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_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"); +} + +/// 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}"); +} + +/// 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}"); +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 3fc4c6eb..e76f3888 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -854,6 +854,14 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool { // counts the touch and the other misses it. Move the ray off such points: a shift far below // any modelling tolerance cannot change the answer for a point that is not on the loop. let scale = 1.0 + p.x.abs().max(p.y.abs()); + // A point clear of the loop's box (by more than the ray shifts below) is outside it: skip + // the costly tests, which a region with a thousand cut-outs runs for every probe (#732). + if let Some((lo, hi)) = loop_box(lp) { + let m = scale * 1e-6; + if p.x < lo.x - m || p.x > hi.x + m || p.y < lo.y - m || p.y > hi.y + m { + return false; + } + } // A point on the loop keeps the half-open answer. if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) { return inside_loop_ray(lp, p); @@ -872,6 +880,24 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool { inside_loop_ray(lp, ray) } +/// A box holding a loop of lines and arcs (an arc's whole circle); None for other curves. +fn loop_box(lp: &Loop2) -> Option<(Vec2, Vec2)> { + let mut lo = Vec2::new(f64::MAX, f64::MAX); + let mut hi = Vec2::new(f64::MIN, f64::MIN); + let mut add = |a: Vec2, b: Vec2| { + lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y)); + hi = Vec2::new(hi.x.max(b.x), hi.y.max(b.y)); + }; + for seg in &lp.segs { + match *seg { + Seg2::Line { a, b } => add(Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y))), + Seg2::Arc { center, radius, .. } => add(center - Vec2::new(radius.abs(), radius.abs()), center + Vec2::new(radius.abs(), radius.abs())), + _ => return None, + } + } + (lo.x <= hi.x && lo.y <= hi.y && [lo.x, lo.y, hi.x, hi.y].iter().all(|v| v.is_finite())).then_some((lo, hi)) +} + /// [`inside_loop`] with the ray along +x from `p` taken as given. fn inside_loop_ray(lp: &Loop2, p: Vec2) -> bool { let mut odd = false;