Files
solidcraft-upstream/crates/engine/src/cmd/sheet_tests.rs
T

1561 lines
83 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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!(solvecraft_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(solvecraft_geom::Vec3::new(25.0, 25.0, 0.5)));
assert!(body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 1.5)));
}
let mut reopened = Session::new(solvecraft_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!(solvecraft_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(solvecraft_geom::Vec3::new(50.0, 30.0, 1.0)));
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.0)));
assert!(body.contains(solvecraft_geom::Vec3::new(64.0, 30.0, 1.0)));
assert!(!body.contains(solvecraft_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!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 6);
}
#[test]
fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() {
use solvecraft_doc::{apply_point, apply_vector};
use solvecraft_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(solvecraft_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(solvecraft_geom::Vec3::new(25.0, 25.0, -0.5)));
assert!(state.bodies[0].body.contains(solvecraft_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(solvecraft_geom::Vec3::new(x, 25.0, 1.0))));
}
assert!(!state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(50.0, 25.0, 1.0))));
assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solvecraft_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!(solvecraft_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 solvecraft_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(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5;
let floor = sh.frame.to_world(solvecraft_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!((solvecraft_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(solvecraft_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 is not supported yet, and says so.
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);
let e = c.execute("sheet.fold", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[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}");
}
}
}
#[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 solvecraft_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 = solvecraft_io::read_dxf(&dxf).unwrap();
assert_eq!(read.len(), 5);
assert!(read.iter().any(|g| matches!(g, solvecraft_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 = solvecraft_io::read_dxf(&dxf).unwrap();
assert_eq!(read.len(), 6);
assert!(read.iter().any(|g| matches!(g, solvecraft_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!(solvecraft_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!(solvecraft_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, solvecraft_geom::Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
let expected = regions.iter().map(solvecraft_geom::Region2::area).sum::<f64>() * 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);
}
}
#[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!(solvecraft_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(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25)));
assert!(!body.contains(solvecraft_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(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25)));
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25)));
assert!(body.contains(solvecraft_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(solvecraft_geom::Vec2::new(50.0, 30.0))));
assert!(!regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(57.0, 30.0))));
assert!(regions.iter().any(|r| r.contains(solvecraft_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 solvecraft_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: solvecraft_geom::Vec3, outside: solvecraft_geom::Vec3) {
let path = std::env::temp_dir().join(format!("solvecraft-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 solvecraft_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!(solvecraft_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(solvecraft_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 solvecraft_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(solvecraft_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 solvecraft_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!(solvecraft_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 solvecraft_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!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6);
}
let mut reopened = Session::new(solvecraft_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!((solvecraft_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!((solvecraft_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 solvecraft_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(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
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(solvecraft_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!((solvecraft_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(solvecraft_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 = solvecraft_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(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let world = cold.world_state();
let body = &world.body(&name).unwrap().body;
assert!((solvecraft_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 solvecraft_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!((solvecraft_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!((solvecraft_kernel::measure(&state.body("Roof").unwrap().body).unwrap().volume - 100.0).abs() < 1e-6);
}
let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((solvecraft_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!((solvecraft_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 solvecraft_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(solvecraft_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!((solvecraft_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 = solvecraft_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(solvecraft_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!(solvecraft_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!("solvecraft-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!((solvecraft_kernel::measure(&s.world_state().body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-5);
}
}