Files
OneCAD/crates/engine/src/cmd/features_more_tests.rs
T
2026-10-18 06:18:43 -04:00

1555 lines
94 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 serde_json::{Value, json};
use crate::Session;
#[test]
fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name":"Part"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
run(&mut s, "component.activate", json!({"component":0}));
for offset in [0., 30.] {
if offset != 0. {
run(&mut s, "occurrence.move", json!({"occurrence":part["occurrence"],"translate":[offset,0,0]}));
}
// To Object follows the target's side, even when the distance and
// direction fields still describe the opposite extrusion.
for (height, expected) in [(8., "cut"), (12., "join")] {
let params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"face_body":"Stock",
"to":[offset+5.,5,height],"direction":"negative"});
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
}
for distance in [-2., 2.] {
let mut params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"distance":distance});
let expected = if distance < 0. { "cut" } else { "join" };
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
params["face_body"] = json!("Stock");
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
let mut actual = s.scratch();
if offset != 0. {
run(&mut actual, "component.activate", json!({"component":part["component"]}));
}
params["operation"] = json!("auto");
let made = run(&mut actual, "solid.extrude", params);
let body = actual.model.state().body("Stock").unwrap().body.clone();
let volume = solidcraft_kernel::measure(&body).unwrap().volume;
assert!(
(volume - (1000. + 100. * distance)).abs() < 1e-6,
"offset={offset}, distance={distance}, volume={volume}, feature={:?}",
actual.doc.feature(made["feature"].as_u64().unwrap())
);
}
if offset != 0. {
// Outside the edited component, grouping keeps the original explicit
// face command's coordinate and participant behavior.
let mut grouped = s.scratch();
let mut single = s.scratch();
run(&mut grouped, "solid.extrude", json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2,"operation":"cut"}));
run(&mut single, "solid.extrude", json!({"face":[32,2,10],"face_body":"Stock","distance":-2,"operation":"cut"}));
let volume = |session: &Session| solidcraft_kernel::measure(&session.model.state().body("Stock").unwrap().body).unwrap().volume;
assert!((volume(&grouped) - volume(&single)).abs() < 1e-6);
}
}
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
assert_eq!(crate::auto_operation(&s, &params), Some("join"), "an existing active-component body keeps its original participant scope");
params["participants"] = json!(["Stock"]);
assert_eq!(crate::auto_operation(&s, &params), Some("cut"));
params["participants"] = json!(["Other"]);
assert_eq!(crate::auto_operation(&s, &params), Some("join"));
}
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 auto_emboss_params(mut p: Value, auto: bool) -> Value {
if auto {
if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) {
params.remove("face");
} else if let Some(object) = p.as_object_mut() {
object.remove("face");
}
}
p
}
fn measure(s: &mut Session) -> Value {
run(s, "inspect.measure", json!({}))
}
fn volume(s: &mut Session) -> f64 {
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn separated_profile_cut_preserves_participants_and_removes_only_consumed_bodies() {
for explicit in [false, true] {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 10), ("C", 30)] {
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":10,"corner":[x,0,0],"body_name":name}));
}
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Cutters"}));
for (a, b) in [([-1, -1], [6, 6]), ([12, 2], [14, 4]), ([32, 2], [34, 4])] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":a,"p1":b}));
}
run(&mut s, "sketch.finish", json!({}));
let before = s.doc.to_json();
let mut params = json!({"sketch":"Cutters","profiles":"all","distance":10,"operation":"cut"});
if explicit {
params["participants"] = json!(["A", "B"]);
}
run(&mut s, "solid.extrude", params);
let check = |session: &Session| {
let state = session.model.state();
assert_eq!(state.bodies.len(), 2);
assert!(state.body("A").is_none());
for (name, expected) in [("B", 210.), ("C", if explicit { 250. } else { 210. })] {
let body = &state.body(name).unwrap().body;
assert!((solidcraft_kernel::measure(body).unwrap().volume - expected).abs() < 1e-6);
assert!(body.validity().is_empty());
}
};
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
check(&s);
}
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
/// An L bracket: a 40 x 20 x 5 base and a 5 x 20 x 40 wall at x = 0..5.
fn bracket() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
assert!(rel(volume(&mut s), 4000.0 + 3500.0) < 1e-9);
// Mid-plane of the bracket (y = 10): sketch x = X, sketch y = Z.
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "sketch.line", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
s
}
#[test]
fn rib_to_the_next_face_fills_the_corner() {
let mut s = bracket();
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
// The triangle (5,5)-(5,25)-(25,5) of area 200, 4 thick.
assert!(rel(volume(&mut s), 7500.0 + 800.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
// The thickness is a parameter.
let t = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": t, "expression": "2"}));
assert!(rel(volume(&mut s), 7500.0 + 400.0) < 1e-6);
}
#[test]
fn rib_with_a_depth_and_web() {
let mut s = bracket();
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
let v = volume(&mut s);
assert!(v > 7500.0 + 100.0 && v < 7500.0 + 800.0, "{v}");
let mut s = bracket();
run(&mut s, "solid.web", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
assert!(rel(volume(&mut s), 8300.0) < 1e-6);
// Curves that face no body.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
}
#[test]
fn rib_along_the_sketch_normal_reaches_the_body() {
// A 20 x 20 x 10 box; a line along X on a sketch 10 in front of it (y = -10), whose plane
// the box does not cross, so only a rib along the sketch normal can reach it.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Front"}));
run(&mut s, "sketch.line", json!({"points": [[2, 5], [18, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal"}));
// 16 long, 2 thick, 10 from the sketch to the box.
assert!(rel(volume(&mut s), 4000.0 + 320.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal", "depth": 4}));
assert!(rel(volume(&mut s), 4000.0 + 128.0) < 1e-6, "{}", volume(&mut s));
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "sideways"}));
assert!(e.is_err_and(|e| e.to_string().contains("unknown direction")));
}
#[test]
fn emboss_and_deboss_on_a_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 2}));
assert!(rel(volume(&mut s), 12000.0 + 200.0) < 1e-6, "{}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
assert!(rel(volume(&mut s), 12000.0 - 300.0) < 1e-6);
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
}
#[test]
fn emboss_face_off_the_part_says_how_far() {
// #392: a point beside the face was refused with "`face` must identify exactly one target
// body surface", which didn't say the point was off the face.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "face": [20, 15, 11.25]})).unwrap_err().to_string();
assert!(e.contains("[20, 15, 11.25] lies on no face: the nearest, on `A`, is 1.250 mm away"), "{e}");
run(&mut s, "solid.box", json!({"length": 10, "width": 30, "height": 10, "corner": [40, 0, 0], "operation": "new_body", "body_name": "B"}));
let e = s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "face": [40, 15, 5]})).unwrap_err().to_string();
assert!(e.contains("lies on faces of 2 bodies (A, B): name one in `targets`"), "{e}");
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 1, "face": [20, 15, 10]}));
}
fn cylinder_badge() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"}));
run(&mut s, "solid.cylinder", json!({"radius":"10 mm","height":"20 mm","body_name":"Sleeve"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
s
}
fn exact_cylinder_volume(s: &Session) -> f64 {
s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume
}
#[test]
fn cylinder_emboss_persists_selection_and_replays_radial_depth() {
use solidcraft_geom::Vec3;
let mut s = cylinder_badge();
let before = exact_cylinder_volume(&s);
run(
&mut s,
"solid.emboss",
json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":"badge_depth","targets":["Sleeve"],"name":"Raised Badge"}),
);
assert!((exact_cylinder_volume(&s) - before - 21.0).abs() < 0.02);
let state = s.model.state();
let body = &state.body("Sleeve").unwrap().body;
assert_eq!(body.shell_count(), 1);
let mesh = body.tessellate_with(0.001, true).unwrap();
assert!(mesh.contains(Vec3::new(-10.5 * 0.15_f64.sin(), 10.5 * 0.15_f64.cos(), 7.5)));
let feature = s.doc.find_feature("Raised Badge").unwrap();
assert!(
matches!(&feature.kind,solidcraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"&&face.at.dist(Vec3::new(0.,10.,7.5))<1e-10)
);
let saved = s.doc.to_json();
let mut loaded = Session { doc: solidcraft_doc::Document::from_json(&saved).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
assert!((exact_cylinder_volume(&loaded) - before - 21.0).abs() < 0.02);
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2 mm"}));
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
assert!((exact_cylinder_volume(&loaded) - before - 44.0).abs() < 0.02);
}
#[test]
fn cylinder_deboss_is_connected_and_unsupported_profile_failure_is_atomic() {
let mut s = cylinder_badge();
let before = exact_cylinder_volume(&s);
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[0,10,7.5],"depth":1,"mode":"deboss","targets":["Sleeve"]}));
assert!((exact_cylinder_volume(&s) - before + 19.0).abs() < 0.02);
assert_eq!(s.model.state().body("Sleeve").unwrap().body.shell_count(), 1);
let mut bad = cylinder_badge();
run(&mut bad, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Circle Badge"}));
run(&mut bad, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
run(&mut bad, "sketch.finish", json!({}));
let saved = bad.doc.to_json();
let v = exact_cylinder_volume(&bad);
assert!(bad.execute("solid.emboss", &json!({"sketch":"Circle Badge","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})).is_err());
assert_eq!(bad.doc.to_json(), saved);
assert_eq!(exact_cylinder_volume(&bad), v);
}
#[test]
fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":"0.1mm","height":"0.2mm","body_name":"Tiny"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0.1,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.02,0.05],"p1":[0.02,0.1]}));
run(&mut s, "sketch.finish", json!({}));
let mesh = s.model.state().bodies.first().unwrap().mesh();
let pick = mesh
.triangles
.iter()
.filter_map(|tri| mesh.tri(tri))
.map(|[a, b, c]| (a + b + c) / 3.)
.find(|p| p.y > 0.08 && p.z > 0.04 && p.z < 0.16)
.unwrap();
assert!(((pick.x * pick.x + pick.y * pick.y).sqrt() - 0.1).abs() > 1e-7);
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[pick.x,pick.y,pick.z],"depth":"0.01mm","targets":["Tiny"]}));
let solidcraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
panic!("missing face");
};
assert!(((face.at.x * face.at.x + face.at.y * face.at.y).sqrt() - 0.1).abs() < 1e-12);
assert_eq!(s.model.state().bodies.first().unwrap().body.shell_count(), 1);
}
#[test]
fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] {
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine")));
let mut s = Session::default();
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let source_feature = s.model.state().bodies.first().unwrap().feature;
let source_names = solidcraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
let before = exact_cylinder_volume(&s);
run(&mut s, "occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]}));
let second = run(&mut s, "occurrence.copy", json!({"component":a,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":"root"}));
let author = run(&mut s, "component.create", json!({"name":"Author"}))["component"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]}));
let author_second = run(&mut s, "occurrence.copy", json!({"component":author,"translate":[0,100,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":author,"occurrence":author_second}));
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1mm"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[40.+pick_offset,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
let placements = serde_json::to_value(&s.doc.occurrences).unwrap();
let target = s
.world_state()
.bodies
.iter()
.find(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solidcraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5)))
.unwrap()
.name
.clone();
run(
&mut s,
"solid.emboss",
json!({"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}),
);
let solidcraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
panic!("missing selection");
};
assert_eq!(face.path, if pick_offset == 0. { s.doc.first_path(a) } else { vec![second] });
assert_eq!(face.author_path, vec![author_second]);
let state = s.model.state();
let body = state.body("Sleeve").unwrap();
assert_eq!(body.feature, source_feature);
assert_eq!(s.doc.body_component("Sleeve", body.feature), a);
assert_eq!(serde_json::to_value(&s.doc.occurrences).unwrap(), placements);
let selected = s.doc.features.last().unwrap().face_names.first().unwrap();
assert!(!selected.is_empty() && source_names.contains(selected), "{selected:?} not in {source_names:?}");
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
let world = s.world_state();
for x in [39.5, 119.5] {
assert!(world.bodies.iter().any(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solidcraft_geom::Vec3::new(x, 0., 7.5))));
}
let mut loaded = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
assert!((exact_cylinder_volume(&loaded) - before - 44.).abs() < 0.02);
assert_eq!(loaded.doc.body_component("Sleeve", loaded.model.state().body("Sleeve").unwrap().feature), a);
assert_eq!(serde_json::to_value(&loaded.doc.occurrences).unwrap(), placements);
let feature = loaded.doc.features.last().unwrap().clone();
run(&mut loaded, "component.activate", json!({"component":"root"}));
run(
&mut loaded,
"timeline.redefine",
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}}),
);
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
run(
&mut loaded,
"timeline.redefine",
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"3mm","targets":[target]}}),
);
assert!((exact_cylinder_volume(&loaded) - before - 69.).abs() < 0.02);
let mut broken = loaded.doc.as_ref().clone();
let id = broken.features.last().unwrap().id;
let solidcraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &mut broken.feature_mut(id).unwrap().kind else {
panic!("missing selection");
};
face.path = vec![u64::MAX];
let mut invalid = Session { doc: broken.into(), ..Default::default() };
invalid.refresh();
assert!(invalid.model.results.iter().find(|r| r.id == id).unwrap().error.as_ref().unwrap().contains("placement"));
assert!((exact_cylinder_volume(&invalid) - before).abs() < 0.02);
}
}
#[test]
fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
check_active_second_source(false);
check_active_second_source(true);
}
fn check_active_second_source(auto: bool) {
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let before = exact_cylinder_volume(&s);
let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":component,"occurrence":second}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[80,10,7.5],"depth":1}));
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
let solidcraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
panic!("missing selection");
};
assert_eq!(face.path, vec![second]);
assert_eq!(face.author_path, vec![second]);
let mut loaded = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
assert!((exact_cylinder_volume(&loaded) - before - 21.).abs() < 0.02);
}
#[test]
fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] {
let mut s = Session::default();
let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap();
let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "occurrence.move", json!({"occurrence":if ancestor {parent}else{leaf},"translate":[5,0,0],"capture":false}));
let before = s.doc.to_json();
let pending = s.pending_moves.clone();
let volume = exact_cylinder_volume(&s);
let error = s
.execute("solid.emboss", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto))
.unwrap_err()
.to_string();
assert!(error.contains("Capture Position"), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.pending_moves, pending);
assert_eq!(exact_cylinder_volume(&s), volume);
}
}
#[test]
fn cylinder_pending_third_component_profile_requires_capture_atomically() {
check_pending_third_profile(false);
check_pending_third_profile(true);
}
fn check_pending_third_profile(auto: bool) {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "component.create", json!({"name":"Author"}));
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[5,0,0],"capture":false}));
let before = s.doc.to_json();
let pending = s.pending_moves.clone();
let volume = exact_cylinder_volume(&s);
let error = s
.execute(
"solid.emboss",
&auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto),
)
.unwrap_err()
.to_string();
assert!(error.contains("Capture Position"), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.pending_moves, pending);
assert_eq!(exact_cylinder_volume(&s), volume);
}
#[test]
fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() {
check_body_offset(false);
check_body_offset(true);
}
fn check_body_offset(auto: bool) {
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
let before = exact_cylinder_volume(&s);
let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]}));
std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder);
std::sync::Arc::make_mut(&mut s.doc)
.body_offsets
.insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]);
s.refresh();
assert!(s.doc.body_offsets.contains_key("Sleeve"));
let offset = s.doc.body_offsets["Sleeve"];
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}));
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
assert_eq!(s.doc.body_offsets["Sleeve"], offset);
assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder);
assert!(s.world_state().body("Sleeve").unwrap().body.tessellate_with(0.001, true).unwrap().contains(solidcraft_geom::Vec3::new(0., 10.5, 7.5)));
}
#[test]
fn replace_face_align_remove() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.replace_face", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
assert!(rel(volume(&mut s), 1500.0) < 1e-6);
let e = s.execute("solid.replace_face", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
assert!(e.is_err() || s.model.results.last().is_some_and(|r| r.error.as_deref().unwrap_or("").contains("parallel")));
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
// B's top face onto A's top face: B turns over and sits on A.
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
for (got, want) in [(&lo[0], 0.0), (&lo[2], 10.0), (&hi[0], 10.0), (&hi[2], 20.0)] {
assert!((got.as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{b}");
}
// Points only: a plain move.
run(&mut s, "solid.align", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
assert!((b["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "{b}");
run(&mut s, "solid.remove", json!({"bodies": ["B"]}));
assert_eq!(measure(&mut s)["body_count"], 1);
}
#[test]
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
let mut s = Session::default();
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
assert!(rel(volume(&mut s), PI * 4.0 * TAU * 20.0 * 2.0) < 1e-3, "{}", volume(&mut s));
// Height and revolutions give the pitch; a square section outside the diameter.
let mut s = Session::default();
run(
&mut s,
"solid.coil",
json!({"diameter": 40, "revolutions": 1.5, "height": 15, "section": "square", "section_size": 2, "section_position": "outside", "axis": "X"}),
);
assert!(rel(volume(&mut s), 4.0 * TAU * 21.0 * 1.5) < 1e-3, "{}", volume(&mut s));
assert!(s.execute("solid.coil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
// Pyramid: a square lofted to a point.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "sketch.point", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
}
/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline
/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top
/// edges lie on the outer walls; the cut used to fail for one order of the sections (an
/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed").
fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result<Value, String>) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]}));
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]}));
run(&mut s, "sketch.finish", json!({}));
let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})];
if reverse {
secs.reverse();
}
let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string());
(s, r)
}
#[test]
fn loft_cut_through_a_shelled_basin_in_either_order() {
// What is left after the cut: the basin less the loft's part of the floor and walls
// (integrated over the height), the same for a pad in any corner.
let want = 41582.028;
for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] {
for reverse in [false, true] {
let (mut s, r) = basin_fall(pad, reverse, "cut");
assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}");
let v = volume(&mut s);
assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}");
assert_eq!(measure(&mut s)["body_count"], 1);
}
}
// As a new body and joined: a valid solid of positive volume either way.
for op in ["new", "join"] {
let mut vols = Vec::new();
for reverse in [false, true] {
let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op);
assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}");
vols.push(volume(&mut s));
}
assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}");
}
}
#[test]
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
use solidcraft_geom::Vec3;
for mode in ["emboss", "deboss"] {
let mut s = cylinder_badge();
let before = exact_cylinder_volume(&s);
let initial = s.doc.to_json();
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"}));
let feature = s.doc.find_feature("Auto Badge").unwrap().clone();
let solidcraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else {
panic!("missing inferred association");
};
assert_eq!(targets, &["Sleeve"]);
assert_eq!(face.body, "Sleeve");
assert!(face.at.z > 0. && face.at.z < 20.);
assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10);
assert!(!feature.face_names.first().unwrap().is_empty());
let expected = if mode == "emboss" { 21. } else { -19. };
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
let state = s.model.state();
let body = &state.body("Sleeve").unwrap().body;
assert_eq!(body.shell_count(), 1);
assert_eq!(
body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)),
mode == "emboss"
);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), initial);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind);
let mut loaded = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
loaded.refresh();
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
run(
&mut loaded,
"timeline.redefine",
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}),
);
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02);
}
}
#[test]
fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() {
for remote_first in [false, true] {
let mut s = Session::default();
for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } {
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name}));
}
run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"}));
run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"}));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
let before = exact_cylinder_volume(&s);
let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1}));
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote);
}
}
#[test]
fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() {
use solidcraft_geom::Vec3;
for mode in ["emboss", "deboss"] {
let mut s = cylinder_badge();
run(&mut s, "sketch.edit", json!({"sketch":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]}));
run(&mut s, "sketch.finish", json!({}));
let before = exact_cylinder_volume(&s);
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode}));
let depth = if mode == "emboss" { 1. } else { -1. };
let expected = 16. * (depth + depth * depth / 20.);
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
let st = s.model.state();
let body = &st.body("Sleeve").unwrap().body;
assert_eq!(body.shell_count(), 1);
let mesh = body.tessellate_with(0.001, true).unwrap();
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
let theta = s / 10.0_f64;
let radius = if mode == "emboss" { 10.5 } else { 9.5 };
assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised });
}
}
}
#[test]
fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() {
let mut s = cylinder_badge();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"}));
let before = s.doc.to_json();
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string();
assert!(error.contains("multiple tangent cylinders"), "{error}");
assert_eq!(s.doc.to_json(), before);
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]}));
assert!(matches!(&s.doc.features.last().unwrap().kind,solidcraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"));
for circle in [false, true] {
let mut s = cylinder_badge();
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"}));
if circle {
run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
} else {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]}));
}
run(&mut s, "sketch.finish", json!({}));
let before = s.doc.to_json();
let volume = exact_cylinder_volume(&s);
let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string();
assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(exact_cylinder_volume(&s), volume);
}
}
/// The design from #396: a loft from a Ø12 circle on a 40 × 30 × 20 box's top to a hexagon 15 up.
fn loft_on_box(op: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 20, "body_name": "Box"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "A"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 35, "name": "B"}));
run(&mut s, "sketch.create", json!({"plane": "A", "name": "L0"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 12}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "B", "name": "L1"}));
run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 4, "sides": 6}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "L0"}, {"sketch": "L1"}], "operation": op}));
s
}
#[test]
fn loft_joined_onto_a_box_exports_one_connected_mesh() {
// #396: one body, but its two shells only touched, and the STL came out in two pieces.
let mut apart = loft_on_box("new_body");
let m = measure(&mut apart);
assert_eq!(m["body_count"], 2);
let loft = m["bodies"][1]["volume_mm3"].as_f64().unwrap();
let mut s = loft_on_box("join");
let m = measure(&mut s);
assert_eq!(m["body_count"], 1);
assert_eq!(m["bodies"][0]["kernel"]["shells"], 1);
let v = volume(&mut s);
assert!(rel(v, 24000.0 + loft) < 1e-5, "{v} vs {}", 24000.0 + loft);
// The exported mesh, its vertices merged, is one connected piece.
let mesh = s.model.state().body("Box").unwrap().body.tessellate(0.01).unwrap();
let (positions, triangles) = solidcraft_io::weld(&mesh.positions, &mesh.triangles, 1e-4);
let mut piece: Vec<usize> = (0..positions.len()).collect();
let root = |piece: &mut Vec<usize>, mut i: usize| {
while piece[i] != i {
i = piece[i];
}
i
};
for t in &triangles {
let r0 = root(&mut piece, t[0] as usize);
for k in [1, 2] {
let rk = root(&mut piece, t[k] as usize);
piece[rk] = r0;
}
}
let pieces: std::collections::BTreeSet<usize> = triangles.iter().map(|t| root(&mut piece, t[0] as usize)).collect();
assert_eq!(pieces.len(), 1);
}
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
#[test]
fn loft_intersect_that_trims_one_edge_changes_the_body() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"}));
for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] {
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius}));
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"}));
// r 35 up to z = 9, then a frustum to r 34 at z = 10.
let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0);
let got = volume(&mut s);
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
let m = measure(&mut s);
assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}");
}
/// #623: a moulded front frame (rounded block, recess, through opening, four pockets from the back, two joined ribs,
/// one joined Ø7 boss) and a Ø3.2 x 8 blind bore into the boss. Reduced from the issue's script; on 0.7.0 the bore
/// fails on every run with "lumps: cannot mesh a complete shell boundary" (the original failed on about half).
const RECIPE_623: &str = r#"{"commands":[{"command":"component.activate","params":{"component":"root"}},{"command":"sketch.create","params":{"plane":{"origin":[466.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Outline Sketch"}},{"command":"sketch.line","params":{"points":[[9.0,17.35],[231.0,17.35]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[231.0,25.5],"start":[231.0,17.35],"end":[239.15,25.5]}},{"command":"sketch.line","params":{"points":[[239.15,25.5],[239.15,500.0]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[231.0,500.0],"start":[239.15,500.0],"end":[231.0,508.15]}},{"command":"sketch.line","params":{"points":[[231.0,508.15],[9.0,508.15]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[9.0,500.0],"start":[9.0,508.15],"end":[0.8500000000000001,500.0]}},{"command":"sketch.line","params":{"points":[[0.8500000000000001,500.0],[0.8500000000000001,25.5]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[9.0,25.5],"start":[0.8500000000000001,25.5],"end":[9.0,17.35]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Outline Sketch","distance":30.0,"operation":"new","direction":"positive","name":"PL01 Outline","body_name":"PL01 Front Frame"}},{"command":"sketch.create","params":{"plane":{"origin":[496.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Recess Sketch"}},{"command":"sketch.line","params":{"points":[[10.0,22.5],[230.0,22.5]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[230.0,26.5],"start":[230.0,22.5],"end":[234.0,26.5]}},{"command":"sketch.line","params":{"points":[[234.0,26.5],[234.0,499.0]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[230.0,499.0],"start":[234.0,499.0],"end":[230.0,503.0]}},{"command":"sketch.line","params":{"points":[[230.0,503.0],[10.0,503.0]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[10.0,499.0],"start":[10.0,503.0],"end":[6.0,499.0]}},{"command":"sketch.line","params":{"points":[[6.0,499.0],[6.0,26.5]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[10.0,26.5],"start":[6.0,26.5],"end":[10.0,22.5]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Recess Sketch","distance":12.0,"operation":"cut","direction":"negative","name":"PL01 Recess","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[484.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Opening Sketch"}},{"command":"sketch.line","params":{"points":[[20.5,36.0],[219.5,36.0]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[219.5,38.0],"start":[219.5,36.0],"end":[221.5,38.0]}},{"command":"sketch.line","params":{"points":[[221.5,38.0],[221.5,488.5]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[219.5,488.5],"start":[221.5,488.5],"end":[219.5,490.5]}},{"command":"sketch.line","params":{"points":[[219.5,490.5],[20.5,490.5]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[20.5,488.5],"start":[20.5,490.5],"end":[18.5,488.5]}},{"command":"sketch.line","params":{"points":[[18.5,488.5],[18.5,38.0]],"closed":false}},{"command":"sketch.arc.center_point","params":{"center":[20.5,38.0],"start":[18.5,38.0],"end":[20.5,36.0]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Opening Sketch","distance":18.0,"operation":"cut","direction":"negative","name":"PL01 Opening","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[466.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Hollow 1 Sketch"}},{"command":"sketch.rectangle.two_point","params":{"p0":[3.35,28.85],"p1":[16.0,496.65]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Hollow 1 Sketch","distance":15.5,"operation":"cut","direction":"positive","name":"PL01 Hollow 1","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[466.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Hollow 2 Sketch"}},{"command":"sketch.rectangle.two_point","params":{"p0":[224.0,28.85],"p1":[236.65,496.65]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Hollow 2 Sketch","distance":15.5,"operation":"cut","direction":"positive","name":"PL01 Hollow 2","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[466.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Hollow 3 Sketch"}},{"command":"sketch.rectangle.two_point","params":{"p0":[12.35,19.85],"p1":[227.65,33.5]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Hollow 3 Sketch","distance":15.5,"operation":"cut","direction":"positive","name":"PL01 Hollow 3","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[466.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Hollow 4 Sketch"}},{"command":"sketch.rectangle.two_point","params":{"p0":[12.35,493.0],"p1":[227.65,505.65]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Hollow 4 Sketch","distance":15.5,"operation":"cut","direction":"positive","name":"PL01 Hollow 4","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[471.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Rib 1 Sketch"}},{"command":"sketch.rectangle.two_point","params":{"p0":[16.0,69.0],"p1":[224.0,73.0]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Rib 1 Sketch","distance":12.0,"operation":"join","direction":"positive","name":"PL01 Rib 1","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[471.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Rib 6 Sketch"}},{"command":"sketch.rectangle.two_point","params":{"p0":[16.0,470.0],"p1":[224.0,474.0]}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Rib 6 Sketch","distance":12.0,"operation":"join","direction":"positive","name":"PL01 Rib 6","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[481.5,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"PL01 Boss 4 Sketch"}},{"command":"sketch.circle.center","params":{"center":[227.5,492.0],"radius":3.5}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"PL01 Boss 4 Sketch","distance":24.5,"operation":"join","direction":"negative","name":"PL01 Boss 4","targets":["PL01 Front Frame"]}},{"command":"sketch.create","params":{"plane":{"origin":[457.0,0,0],"x_dir":[0,1,0],"y_dir":[0,0,1]},"name":"B"}},{"command":"sketch.circle.center","params":{"center":[227.5,492.0],"radius":1.6}},{"command":"sketch.finish","params":{}},{"command":"solid.extrude","params":{"sketch":"B","distance":8.0,"operation":"cut","direction":"positive","name":"B","targets":["PL01 Front Frame"]}}]}"#;
#[test]
fn blind_bore_into_a_joined_boss_meshes() {
let recipe: Value = serde_json::from_str(RECIPE_623).unwrap();
let mut s = Session::default();
for c in recipe["commands"].as_array().unwrap() {
run(&mut s, c["command"].as_str().unwrap(), c["params"].clone());
}
let m = measure(&mut s);
assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{m}");
}
#[test]
fn cutting_a_text_removes_each_letter_whether_its_letters_stand_apart_or_touch() {
// Letters apart (cut at once by all of them), and two touching blocks (cut one after the other).
for (text, touching) in [("SOLIDCRAFT", false), ("", true)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[200,40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
if touching {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[10,10],"p1":[20,20]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,12],"p1":[30,18]}));
} else {
run(&mut s, "sketch.text", json!({"text":text,"at":[5,12],"height":14}));
}
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
// The profiles cut: the letters, not their counters (the inside of an O, whose outline
// is a hole of its letter).
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves)));
let area: f64 = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":2,"operation":"cut"}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
assert!((volume - (200.0 * 40.0 * 10.0 - 2.0 * area)).abs() < 1e-3 * area, "{text:?}: {volume} for {area} mm² cut 2 mm deep");
assert_eq!(s.world_state().bodies.len(), 1);
}
}
/// A text joined onto a block with a taper: its letters are spline and conic outlines whose
/// sides slope, standing on the block's face (run on into it) or hanging over its edge (a
/// straight foot first). An outward taper adds a little more than the straight text; one
/// steeper than the letters' strokes is refused, saying so.
#[test]
fn a_tapered_text_joins_a_block_on_its_face_or_hanging_over_its_edge() {
for (at, hanging) in [([5.0, 12.0], false), ([150.0, 12.0], true)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[200,40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-10}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.text", json!({"text":"tres long","at":at,"height":8}));
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
// The letters' area, counters left out (a letter's hole is its own profile too).
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves)));
let area: f64 = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum();
run(&mut s, "sketch.finish", json!({}));
let steep = s.execute("solid.extrude", &json!({"distance":2,"operation":"join","taper":"15 deg"})).unwrap_err();
assert!(steep.to_string().contains("the taper closes the profile"), "{steep}");
run(&mut s, "solid.extrude", json!({"distance":2,"operation":"join","taper":"3 deg"}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let (block, text) = (200.0 * 40.0 * 10.0, 2.0 * area);
assert!(volume > block + text && volume < block + 1.3 * text, "hanging {hanging}: {volume} for {text} mm³ of straight text");
assert_eq!(s.world_state().bodies.len(), 1);
}
}
/// A tapered foot extruded 3 mm down from a part's bottom face and joined (#1534). Run on into
/// the part with their slope, sides that narrow the foot away from the face widened it past the
/// face: a box got a lip round its walls (0.16 mm wide, and nothing said), a cylinder's join found
/// no result, and sides that widen the foot met the face's edge there. A foot on the whole face is
/// sewn on along it; one drawn just inside the face's edge starts on it. Either way: the part plus
/// the frustum, and nothing beside the part's walls.
#[test]
fn a_tapered_foot_under_a_part_joins_as_drawn() {
// (round, the foot's half width: the whole face, or just inside its edge)
for (round, half) in [(false, 15.0), (true, 15.0), (false, 14.9)] {
for taper in [-20.0f64, 20.0] {
let t = taper.to_radians().tan();
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
if round {
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":15}));
} else {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,-15],"p1":[15,15]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"B"}));
let taper_deg = format!("{taper} deg");
if half < 15.0 {
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-half,-half],"p1":[half,half]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":3,"direction":"negative","operation":"join","taper":taper_deg}));
} else {
let foot = json!({"face":[5,1,0],"face_body":"B","distance":3,"direction":"positive","operation":"join","taper":taper_deg});
run(&mut s, "solid.extrude", foot);
}
let (part, frustum) = if round {
let r = 15.0 + 3.0 * t;
(std::f64::consts::PI * 225.0 * 10.0, std::f64::consts::PI * (225.0 + 15.0 * r + r * r))
} else {
let side = 2.0 * half + 6.0 * t;
(9000.0, 4.0 * half * half + 2.0 * half * side + side * side)
};
let v = volume(&mut s);
// The cylinder is measured on its mesh; a foot that starts on the face stands straight
// for a hair first.
let tol = if round || half < 15.0 { 2e-4 } else { 1e-9 } * (part + frustum);
assert!((v - (part + frustum)).abs() < tol, "round {round}, half {half}, taper {taper}: {v} for {}", part + frustum);
assert_eq!(s.world_state().bodies.len(), 1);
let state = s.model.state();
let mesh = state.body("B").unwrap().mesh();
// Beside the part, above the foot's start: nothing.
let out = |x: f64, y: f64| if round { x.hypot(y) } else { x.abs().max(y.abs()) } > 15.0 + 1e-6;
let beside = mesh.positions.iter().filter(|p| p.z > 1e-6 && out(p.x, p.y)).count();
assert_eq!(beside, 0, "round {round}, half {half}, taper {taper}: {:?}", mesh.bounds());
}
}
}
/// A drafted extrude that starts above its sketch plane. The start offset moves the profile
/// along the sketch normal and the walls are drafted from there, so the section at the start
/// plane is the profile itself and the solid is the frustum over it, moved along the normal by
/// the offset. Controls that build today: the same extrude with the taper and no offset, and
/// with the offset and no taper. Two-sided and symmetric tapers are still refused, by name.
#[test]
fn a_drafted_extrude_starts_at_an_offset() {
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
// A 30 x 20 profile, 12 mm long, drafted 6 deg, started 4 mm along the normal.
let (w, d, h, off) = (30.0, 20.0, 12.0, 4.0);
let part = |p: Value| -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", p);
s
};
// The straight prism, and the frustum over the profile: every edge moves out by z*tan a
// over the start, so A(z) = (w + 2 z t)(d + 2 z t) and
// V = integral of A over 0..h = w*d*h + (w + d)*t*h^2 + 4/3*t^2*h^3 (exact: A is quadratic).
let prism = w * d * h;
let frustum = |t: f64| w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
// The two caps plus four trapezoid walls, each (base + top)/2 by the slant height h/cos a.
let area_of = |a: f64| {
let t = a.tan();
w * d + (w + 2.0 * h * t) * (d + 2.0 * h * t) + 2.0 * h / a.cos() * ((w + h * t) + (d + h * t))
};
// Control: the taper alone, from the sketch plane.
let want = frustum(6f64.to_radians().tan());
let mut s = part(json!({"distance":h,"taper":"6 deg","body_name":"B"}));
let v = volume(&mut s);
assert!(rel(v, want) < 1e-9, "taper alone: {v} mm3 for {want}");
// Control: the offset alone, no taper: the prism, moved along the normal.
let mut s = part(json!({"distance":h,"start_offset":off,"body_name":"B"}));
let v = volume(&mut s);
assert!(rel(v, prism) < 1e-9, "offset alone: {v} mm3 for {prism}");
let st = s.model.state();
let m = measure_body(&st.body("B").unwrap().body).unwrap();
assert!((m.bbox.min.z - off).abs() < 1e-9 && (m.bbox.max.z - (off + h)).abs() < 1e-9, "offset alone: {:?}", m.bbox);
// Both: the frustum over the profile, standing on the offset plane, either way along the
// normal and drafted either outward or inward.
for deg in [6.0f64, -6.0] {
for (dir, sign) in [("positive", 1.0f64), ("negative", -1.0)] {
let (ang, t) = (deg.to_radians(), deg.to_radians().tan());
let (v_want, a_want) = (frustum(t), area_of(ang));
let at = format!("{deg} deg {dir}");
let mut s = part(json!({"distance":h,"direction":dir,"start_offset":off,"taper":format!("{deg} deg"),"body_name":"B"}));
let v = volume(&mut s);
assert!(rel(v, v_want) < 1e-9, "{at}: {v} mm3 for {v_want}");
let st = s.model.state();
let b = &st.body("B").unwrap().body;
let m = measure_body(b).unwrap();
assert_eq!(st.bodies.len(), 1, "{at}: one body");
assert_eq!(m.shells, 1, "{at}: one lump");
assert_eq!(m.merged.faces, 6, "{at}: four walls and two caps");
// The area pins both caps: the profile at the offset plane, the drafted section at
// the far end. (The volume alone cannot: it is measured on a tessellation.)
assert!(rel(m.area, a_want) < 1e-9, "{at}: {} mm2 for {a_want}", m.area);
// The solid lies between the offset plane and the far end, and the widening (or
// narrowing) is all on the far side of the profile.
let (lo, far) = if sign > 0.0 { (off, off + h) } else { (off - h, off) };
assert!((m.bbox.min.z - lo).abs() < 1e-9 && (m.bbox.max.z - (lo + h)).abs() < 1e-9, "{at}: {:?}", m.bbox);
let (x0, x1) = (0f64.min(-h * t), w.max(w + h * t));
let (y0, y1) = (0f64.min(-h * t), d.max(d + h * t));
assert!((m.bbox.min.x - x0).abs() < 1e-9 && (m.bbox.max.x - x1).abs() < 1e-9, "{at}: {:?} for x {x0}..{x1}", m.bbox);
assert!((m.bbox.min.y - y0).abs() < 1e-9 && (m.bbox.max.y - y1).abs() < 1e-9, "{at}: {:?} for y {y0}..{y1}", m.bbox);
// Every face names an exact surface: four planes leaning by the taper and two flat
// caps, one of them the profile's plane at the offset. A tessellated stand-in
// measures nearly the same volume and names no surface at all.
let (mut walls, mut caps) = (0, 0);
for i in 0..b.face_count() {
match face_surface(b, i) {
Some(FaceSurface::Plane { normal, point }) if normal.z.abs() > 1.0 - 1e-9 => {
caps += 1;
assert!((point.z - lo).abs() < 1e-9 || (point.z - far).abs() < 1e-9, "{at}: a cap at z = {}", point.z);
}
Some(FaceSurface::Plane { normal, .. }) => {
walls += 1;
assert!((normal.z + sign * ang.sin()).abs() < 1e-9, "{at}: a wall leaning {}, not {}", normal.z, -sign * ang.sin());
}
other => panic!("{at}: face {i} is {other:?}"),
}
}
assert_eq!((walls, caps), (4, 2), "{at}");
}
}
// The extents that are still to come are refused, and say which they are.
for p in [json!({"distance":h,"distance2":5,"taper":"6 deg"}), json!({"distance":h,"direction":"symmetric","taper":"6 deg"})] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.extrude", &p).unwrap_err().to_string();
assert!(e.contains("not supported yet: taper with two-sided or symmetric extents"), "{p}: {e}");
}
}
/// A drafted pocket that starts above its sketch plane, cut into a body three booleans deep:
/// its rim lies on a face bounded by an intersection curve, not by straight lines. The tool
/// lies wholly in material, so the cut removes exactly the frustum over its profile.
#[test]
fn a_drafted_offset_cut_into_a_body_with_history() {
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
let (w, d, h, off) = (30.0, 20.0, 12.0, 18.0);
let (ang, t) = (6f64.to_radians(), 6f64.to_radians().tan());
let frustum = w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
let unnamed = |b: &solidcraft_kernel::Body| (0..b.face_count()).filter(|i| face_surface(b, *i).is_none()).count();
let mut s = Session::default();
// A 60 x 40 x 30 block, a round boss joined through its far side and out of its top, and a
// round pocket cut in the top: the top face is bounded by the boss's intersection curve.
run(&mut s, "solid.box", json!({"length":60,"width":40,"height":30,"corner":[0,0,0],"body_name":"P"}));
run(&mut s, "solid.cylinder", json!({"radius":8,"height":46,"base":[58,20,-8],"operation":"join"}));
run(&mut s, "solid.cylinder", json!({"radius":4,"height":12,"base":[10,34,20],"operation":"cut"}));
let before = volume(&mut s);
{
let st = s.model.state();
assert_eq!(st.bodies.len(), 1, "the block, the boss and the pocket are one body");
assert_eq!(unnamed(&st.body("P").unwrap().body), 0, "faces without a surface before the cut");
}
// The pocket: drafted 6 deg outward, starting 18 mm up the normal and out through the top.
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[35,25]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":h,"start_offset":off,"taper":"6 deg","operation":"cut"}));
let after = volume(&mut s);
let removed = before - after;
assert!((removed - frustum).abs() < 1e-6 * frustum, "{removed} mm3 removed for the frustum's {frustum}");
let st = s.model.state();
let b = &st.body("P").unwrap().body;
let m = measure_body(b).unwrap();
assert_eq!(st.bodies.len(), 1, "one body");
assert_eq!(m.shells, 1, "one lump: the pocket is open at the top");
assert_eq!(unnamed(b), 0, "faces without a surface after the cut");
// Four drafted walls, and the pocket's floor at the offset: the only leaning planes of the
// body (the block's own faces are level or upright), and the only face in the plane the cut
// starts on. A cut keeps the tool's sides facing into the pocket, so a wall drafted outward
// by a leans by +sin a, the other way round from the same extrude as a new body.
let (mut walls, mut floor) = (0, 0);
for i in 0..b.face_count() {
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, i) else { continue };
if (normal.z - ang.sin()).abs() < 1e-9 {
walls += 1;
} else if normal.z > 1.0 - 1e-9 && (point.z - off).abs() < 1e-9 {
floor += 1;
} else {
assert!(normal.z.abs() < 1e-9 || normal.z.abs() > 1.0 - 1e-9, "a plane leaning {}, neither the block's nor the pocket's", normal.z);
}
}
assert_eq!((walls, floor), (4, 1), "four drafted walls, and the pocket's floor at z = {off}");
}
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
/// face and in a knife-edge tip where it runs into the back face.
fn knife_edge_part() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.0, 0.0], "p1": [41.5, -21.0]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "text_at": [36.5, -8.5], "value": "21 mm"}));
run(&mut s, "sketch.line", json!({"infer": true, "points": ["p2", [61.5, -21.0]]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "text_at": [54.5, -26.5], "value": "21"}));
run(&mut s, "sketch.circle.center", json!({"center": "l5.end", "radius": 21.0}));
run(&mut s, "sketch.line", json!({"infer": true, "points": ["p3", [62.166666666666664, 0.0]]}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l6"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"direction": "positive", "distance": "10 mm", "operation": "new", "profiles": [0, 1], "sketch": 1}));
s
}
/// The volume of the session's bodies through the display plan (the measure's mesh cuts across
/// the concave arc). It holds to about 1e-3 of what a blend removes here; the bounds below are
/// about twice what each test reads.
fn planned(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| b.body.display_plan(2e-4).unwrap().evaluate().measure().volume).sum()
}
/// The middle of the part's arc.
const KNIFE_ARC_MID: [f64; 2] = [62.166666666666664 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2];
/// Simpson's rule over the round's corner (2 × 2 less a quarter disc) at distance ρ = 21 + x
/// from the arc's axis, times `length(ρ)`.
fn round_along(length: impl Fn(f64) -> f64) -> f64 {
let (r, n) = (2.0f64, 2000);
let h = r / n as f64;
(0..=n)
.map(|i| {
let x = i as f64 * h;
let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] };
w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * length(21.0 + x)
})
.sum::<f64>()
* h
/ 3.0
}
/// Its edges are stored the other way round from their curves, which once chained the arc
/// with the front edge across their corner. Rounding the arc at the bottom and the top removes
/// the round's section along it, cut off at the tip by the back face's plane: ρ asin(21 / ρ).
#[test]
fn fillet_an_arc_that_runs_into_a_knife_edge_tip() {
let mut s = knife_edge_part();
let before = planned(&s);
let [x, y] = KNIFE_ARC_MID;
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0]], "radius": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 10.0]], "radius": 2}));
let removed = round_along(|rho| rho * (21.0 / rho).asin());
let after = planned(&s);
assert!(rel(before - after, 2.0 * removed) < 2e-3, "removed {} vs {}", before - after, 2.0 * removed);
}
/// The part's front corner (the front face into the arc) rounded with 2 first, then the bottom
/// run (front edge, the corner round's arc, the arc) with 2 as well: a round on a round of the
/// same radius. Along the front edge a straight round; at the corner the ball turns about one
/// point, a piece of sphere (θ r³ / 6 over the corner's θ); along the arc the torus section from
/// the corner round to the tip.
#[test]
fn fillet_a_run_over_a_corner_rounded_with_the_same_radius() {
let mut s = knife_edge_part();
run(&mut s, "solid.fillet", json!({"edges": [[41.1667, -21.0, 5.0]], "radius": 2}));
let before = planned(&s);
let [x, y] = KNIFE_ARC_MID;
// The corner round: about (62.17 - √(23² - 2²), -19), its arc's middle 2 from there.
let cx = 62.166666666666664 - (23.0f64 * 23.0 - 4.0).sqrt();
let joint = 2.0f64.atan2((23.0f64 * 23.0 - 4.0).sqrt());
let theta = std::f64::consts::FRAC_PI_2 - joint;
let corner_mid = [cx + 2.0 * (theta / 2.0).sin(), -19.0 - 2.0 * (theta / 2.0).cos()];
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0], [corner_mid[0], corner_mid[1], 0.0], [20.0, -21.0, 0.0]], "radius": 2}));
let removed = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * cx + theta * 8.0 / 6.0 + round_along(|rho| rho * ((21.0 / rho).asin() - joint));
let after = planned(&s);
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
/// Straight edges of the part ending at its arc wall: the long front edge at the top (the wall
/// cuts the round off at x = 62.17 − √(21² − d²), d from the front face) and the back edge at
/// the top, which runs into the knife-edge tip (x = 62.17 − √(21² − (21 − d)²), d from the back
/// face).
#[test]
fn fillet_straight_edges_that_end_at_the_arc_wall() {
let cx = 62.166666666666664;
for (edge, cut) in [([20.0, -21.0, 10.0], 0.0), ([30.0, 0.0, 10.0], 21.0)] {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [edge], "radius": 2}));
let removed = round_along(|rho| {
let d = rho - 21.0;
cx - (441.0 - (cut - d) * (cut - d)).sqrt()
});
let after = planned(&s);
assert!(rel(before - after, removed) < 3e-3, "{edge:?}: removed {} vs {removed}", before - after);
}
}
/// The vertical knife edge at the tip (the arc and the back face meeting at no angle) rounds to
/// the ball 2 from both, about 13 back from the tip: the cusp beyond the ball, on the tip's side
/// of its contacts, all the way up.
#[test]
fn fillet_the_knife_edge_at_the_tip() {
let cx = 62.166666666666664;
let (kx, ky) = (cx - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0);
let (hx, hy) = (0.002, 0.0005);
let mut area = 0.0;
let mut x = kx + hx / 2.0;
while x < cx {
let lo = -21.0 + (441.0 - (x - cx) * (x - cx)).max(0.0).sqrt();
for j in 0..((0.0 - lo) / hy).ceil() as usize {
let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0));
let y = (y0 + y1) * 0.5;
let a = (y - ky).atan2(x - kx);
if (x - kx).hypot(y - ky) > 2.0 && a >= (-21.0 - ky).atan2(cx - kx) {
area += (y1 - y0) * hx;
}
}
x += hx;
}
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[cx, 0.0, 5.0]], "radius": 2}));
let after = planned(&s);
assert!(rel(before - after, area * 10.0) < 5e-4, "removed {} vs {}", before - after, area * 10.0);
let mut s = knife_edge_part();
run(&mut s, "solid.chamfer", json!({"edges": [[cx, 0.0, 5.0]], "distance": 2}));
assert!(before - planned(&s) > before - after, "the chamfer takes the ball's bulge off too");
}
/// Rounds of the edges at the arc's top front corner, two or all three together (r 2): they meet
/// in creases (with all three, a piece of sphere fills the corner). Each set takes off its rounds
/// alone, less what each alone takes in a box about the corner, plus what they take there
/// together: the CSG grid of the kernel's `rounds_meeting_at_a_corner_of_an_arc_wall`
/// (curvecorner_tests.rs, 0.02 mm cells), within its accuracy.
#[test]
fn fillet_edges_meeting_at_the_front_corner_of_the_arc() {
// In the box about the corner: what the long edge's, the arc's and the upright's rounds take
// alone, and what two or all three take together.
let corner = [2.719176, 3.015864, 1.863];
let together = [(vec![0, 2], 3.886936), (vec![0, 1], 4.971256), (vec![1, 2], 4.19864), (vec![0, 1, 2], 6.3332)];
let [x, y] = KNIFE_ARC_MID;
let (long, arc, upright) = ([20.0, -21.0, 10.0], [x, y, 10.0], [41.1667, -21.0, 5.0]);
let off = |edges: Vec<[f64; 3]>| {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
before - planned(&s)
};
let edges = [long, arc, upright];
let alone = edges.map(|e| off(vec![e]));
for (which, both) in together {
let want = which.iter().map(|i| alone[*i] - corner[*i]).sum::<f64>() + both;
let got = off(which.iter().map(|i| edges[*i]).collect());
assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}");
}
}
/// The long top front edge rounded, then the small front-left upright as a fillet of its own
/// (as in the app): the new round ends in the earlier one, taking off what both take together.
#[test]
fn fillet_the_long_edge_then_the_small_left_edge() {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0]], "radius": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[0.0, -21.0, 5.0]], "radius": 2}));
let one_by_one = before - planned(&s);
let mut s = knife_edge_part();
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2}));
let together = before - planned(&s);
// Within the display meshes' accuracy (the two are built of different faces).
assert!(rel(one_by_one, together) < 2e-4, "one by one {one_by_one} vs together {together}");
}
/// The long top front edge and the front-left vertical edge rounded together (r 2): the long
/// round ends at the arc wall, the two meet at the top-left corner (the top-left edge sharp).
/// Each removes its own round (the long one cut off by the wall), less what both would take at
/// the corner: in its 2 mm cube, what lies outside both quarter circles, ∫ (2 − √(4 − t²))² dt
/// over 0..2 = 16 − 8/3 − 4π.
#[test]
fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2}));
let long = round_along(|rho| 62.166666666666664 - (441.0 - (rho - 21.0) * (rho - 21.0)).sqrt());
let upright = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 10.0;
let removed = long + upright - (16.0 - 8.0 / 3.0 - 4.0 * std::f64::consts::PI);
let after = planned(&s);
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
/// A line of text cut into a block (#391): the boolean came out empty on it after two minutes
/// of retries, at 3.5 mm deep. Two walls of a letter met the block's face 2e-7 mm apart, which
/// truck read as two vertices, and the result's shell did not close. The cut works at any
/// depth now, and takes out the same section at each (the text runs off the block's end, so a
/// little less than the letters).
#[test]
fn a_line_of_text_cuts_into_a_block_at_any_depth() {
let mut removed = Vec::new();
let mut area = 0.0;
for depth in [1.0, 3.5] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-40}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":7.81}));
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
// The letters' area, counters left out (a letter's hole is its own profile too).
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves)));
area = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
removed.push((105.0 * 26.0 * 40.0 - volume) / depth);
assert_eq!(s.world_state().bodies.len(), 1);
}
let (shallow, deep) = (removed[0], removed[1]);
assert!((deep - shallow).abs() < 1e-3 * shallow, "{shallow} mm² taken out at 1 mm, {deep} mm² at 3.5 mm");
assert!(shallow > 0.85 * area && shallow < area, "{shallow} mm² taken out, the letters are {area} mm²");
}
/// A design saved before tapered sides were built through `taper_loop` (#1014), by the build
/// before it: two tapered bosses apart from each other (now joined in one boolean), a fillet on
/// a sloping side edge of one and on the top edge of the other, both saved by edge name. They
/// still find their edges, and the part comes out the same.
#[test]
fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() {
let text = include_str!("../../../../tests/taper/before-1014/two_tapered_bosses_filleted.json");
let mut s = Session::new(solidcraft_doc::Document::from_json(text).unwrap());
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
for name in ["Extrude2", "Fillet1", "Fillet2"] {
let id = s.doc.find_feature(name).unwrap().id;
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.warning.is_none(), "{name}: {:?} {:?}", r.error, r.warning);
}
// The build that saved it measured 69454.885209 mm³.
assert!((volume - 69454.8852092132).abs() < 1e-3, "{volume}");
assert_eq!(s.world_state().bodies.len(), 1);
}
/// #1360: a ring drawn round a hole, its inner circle on the hole's wall, cut into a chamfered
/// disc. The ring's hole lies over the part's hole all the way down the cut, so the tool takes
/// it in (a disc instead of a ring cuts the same): its wall no longer lies on the hole's wall,
/// which the boolean couldn't intersect. Over a hole whose floor the cut reaches, the ring's hole
/// stays: the material under the floor is not cut (the cut may still fail there, as before).
#[test]
fn a_ring_cut_round_a_hole_takes_in_the_hole_it_is_drawn_round() {
let ring = |hole: Value| {
let mut s = Session::default();
for (c, p) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.circle.center", json!({"center":[0,0],"radius":40})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance":10})),
("solid.hole", hole),
("solid.chamfer", json!({"edges":[[40,0,10]],"distance":1})),
("sketch.create", json!({"plane":{"face":[0,0,10]}})),
("sketch.circle.center", json!({"center":[20,0],"radius":3})),
("sketch.circle.center", json!({"center":[20,0],"radius":6})),
("sketch.finish", json!({})),
] {
run(&mut s, c, p);
}
let before = volume(&mut s);
// Profile 2: the ring between the hole's wall and the larger circle.
let r = s.execute("solid.extrude", &json!({"direction":"negative","distance":"3","operation":"cut","profiles":[2]}));
let clean = r
.as_ref()
.ok()
.and_then(|v| v["feature"].as_u64())
.and_then(|id| s.model.result(id))
.is_some_and(|f| f.boolean_diagnostics.events.is_empty());
(r.is_ok(), before - volume(&mut s), clean)
};
let taken = std::f64::consts::PI * (36.0 - 9.0) * 3.0;
let (ok, through, clean) = ring(json!({"position":[20,0,10],"diameter":6}));
assert!(ok && clean && rel(through, taken) < 1e-3, "{through} vs {taken}");
// A blind hole 1 deep: under its floor the ring's hole is material, and nothing of it goes.
let (ok, blind, _) = ring(json!({"position":[20,0,10],"diameter":6,"depth":1}));
assert!(if ok { rel(blind, taken) < 1e-3 } else { blind.abs() < 1e-6 }, "{ok} {blind} vs {taken}");
}
/// #1360: an annulus cut 3 mm into a chamfered disc, its inner circle on a hole's wall. The
/// boolean can't intersect the two walls lying on each other; its last resort (the tool moved by
/// a micron) left a face a hair wide that meshes as its whole plane at a fine step, and the part
/// came back 21000 mm³ larger than before, a square plane across it. A cut may fail there, but it
/// never gives back more than it was given.
#[test]
fn a_cut_flush_with_a_hole_never_grows_the_part() {
let mut s = Session::default();
for (c, p) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.circle.center", json!({"center":[0,0],"radius":40})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance":10})),
("solid.hole", json!({"position":[20,0,10],"diameter":6})),
("solid.chamfer", json!({"edges":[[40,0,10]],"distance":1})),
("sketch.create", json!({"plane":{"face":[0,0,10]}})),
("sketch.circle.center", json!({"center":[20,0],"radius":3})),
("sketch.circle.center", json!({"center":[20,0],"radius":6})),
("sketch.finish", json!({})),
] {
run(&mut s, c, p);
}
let before = volume(&mut s);
// Profile 2: the annulus between the hole's wall and the larger circle.
let r = s.execute("solid.extrude", &json!({"direction":"negative","distance":"3","operation":"cut","profiles":[2]}));
let after = volume(&mut s);
let ring = std::f64::consts::PI * (36.0 - 9.0) * 3.0;
match r {
Ok(_) => assert!(rel(before - after, ring) < 1e-2, "{before} -> {after}, want {ring} taken"),
Err(_) => assert!(rel(after, before) < 1e-9, "{before} -> {after} after a failed cut"),
}
}
/// A line of text cut into a face: the letters' curves (the font's quadratics) are read
/// exactly, and the cut is decided in 2D in one boolean, at any depth and where the text runs
/// off the face. The B-rep boolean failed on many letters at once ("no result" at 3.5 mm, and
/// off the edge), else took one letter at a time on an ever larger body (tens of seconds).
#[test]
fn a_line_of_text_is_cut_in_one_exact_boolean() {
let cut = |at: [f64; 2], depth: f64| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
let before = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":at,"height":7.81}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{at:?} {depth}: {r:?}");
before - volume(&mut s)
};
// Straight walls: the material taken grows with the depth, the letters' area each mm.
let (one, deep) = (cut([3.0, 9.0], 1.0), cut([3.0, 9.0], 3.5));
assert!(rel(deep, 3.5 * one) < 1e-3, "{one} {deep}");
// Off the face's edge, the part of the text still over it.
let off = cut([-6.0, 20.0], 1.0);
assert!(off > 0.0 && off < one, "{off} {one}");
}
/// A line of text cut right through a part, and one joined on in relief, are decided in 2D in
/// one boolean too. Through, each letter's counter (inside an "e" or an "a") comes out as a
/// piece of its own. In relief the letters are joined to the part all at once: one by one, each
/// joined to a part grown by the ones before, it took minutes.
#[test]
fn a_line_of_text_cut_through_or_joined_in_relief_is_one_boolean() {
let text = |op: &str, distance: f64| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
let before = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "solid.extrude", json!({"distance":distance,"operation":op}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{op}: {r:?}");
(before, volume(&mut s), s.model.state().bodies.len())
};
let (before, through, pieces) = text("cut", -40.0);
let (_, one, _) = text("cut", -1.0);
// Through: the letters' area all the way down, with the counters left as pieces.
assert!(pieces > 1, "{pieces}");
assert!(rel(before - through, 40.0 * (before - one)) < 2e-3, "{} vs {}", before - through, 40.0 * (before - one));
// In relief 2 high: twice what a 1 mm cut takes.
let (_, relief, pieces) = text("join", 2.0);
assert_eq!(pieces, 1);
assert!(rel(relief - before, 2.0 * (before - one)) < 2e-3, "{} vs {}", relief - before, 2.0 * (before - one));
}
/// The same line of text cut again, deeper: the new walls lie on the letters' walls. The 2D route
/// takes it as one exact boolean: a point a hair from a segment's end is that end, a sliver of a
/// letter's curve shorter than the tolerance is dropped, and a piece of a letter so short that the
/// engraved walls read it as an arc while the tool has the curve itself is one edge. Before, the
/// sewing found a degenerate edge, then the faces did not close, and the cut went to the B-rep
/// boolean's retries (90 s).
#[test]
fn a_line_of_text_cut_again_deeper_is_one_exact_boolean() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
let cut = |s: &mut Session, depth: f64| {
let before = volume(s);
run(s, "sketch.create", json!({"plane":"XY","offset":40}));
run(s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
run(s, "sketch.finish", json!({}));
let id = run(s, "solid.extrude", json!({"distance":-depth,"operation":"cut"}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{depth}: {r:?}");
before - volume(s)
};
let first = cut(&mut s, 1.0);
// Cut again 2 mm deep: one more millimetre of the letters.
let again = cut(&mut s, 2.0);
assert!(rel(again, first) < 1e-3, "{first} {again}");
}
/// Text cut through a large plate (2 m) is decided in 2D in one exact boolean, as it is in a
/// small one. The 2D route's tolerances grow with the part: a font's curve that is merely near a
/// circle was read as an arc ending a micron off the next curve, so the letter's outline did not
/// close, and where the curves of a 3 come close the arrangement left a sliver that closed up to
/// nothing. The cut went to the B-rep boolean instead, which took tens of seconds and moved the
/// tool to find a result at all.
#[test]
fn text_cut_through_a_large_plate_is_one_exact_boolean() {
let taken = |w: f64, h: f64, at: [f64; 2]| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":w,"width":h,"height":1.5,"corner":[0,0,0]}));
let before = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane":"XY","offset":1.5}));
run(&mut s, "sketch.text", json!({"text":"PART-23","at":at,"height":7}));
run(&mut s, "sketch.finish", json!({}));
let id = run(&mut s, "solid.extrude", json!({"distance":-2,"operation":"cut"}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{w} x {h}: {r:?}");
before - volume(&mut s)
};
let (small, large) = (taken(200.0, 120.0, [60.0, 40.0]), taken(2000.0, 1200.0, [860.0, 125.0]));
// The letters right through the plate, their counters left as pieces of their own: the same
// material in either plate (measured on meshes, which are coarser on the larger one).
assert!(small > 10.0 && rel(large, small) < 5e-2, "{small} {large}");
}