//! Thin Extrude (#721): walls along sketch curves, their volumes worked out by hand (a wall's //! area times its height). 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 body_count(s: &mut Session) -> u64 { run(s, "inspect.measure", json!({}))["body_count"].as_u64().unwrap_or(0) } fn close(a: f64, b: f64) { assert!((a - b).abs() <= 1e-6 * b.abs().max(1.0), "{a} != {b}"); } /// A sketch on XY holding a 40 × 20 rectangle from (5, 5); returns its side ids. fn rectangle(s: &mut Session) -> Vec { run(s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); let r = run(s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [45, 25]})); run(s, "sketch.finish", json!({})); ["bottom", "right", "top", "left"].iter().map(|k| r["sides"][k].as_str().unwrap_or_default().to_string()).collect() } #[test] fn a_rectangle_makes_a_ring_wall_on_either_side() { for (side, extra, area) in [ ("center", json!({}), 42.0 * 22.0 - 38.0 * 18.0), ("inside", json!({}), 40.0 * 20.0 - 36.0 * 16.0), ("outside", json!({}), 44.0 * 24.0 - 40.0 * 20.0), ("two_sides", json!({"thickness2": 1.5}), 43.0 * 23.0 - 39.0 * 19.0), ] { let mut s = Session::default(); rectangle(&mut s); let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": side, "distance": 10}); if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { p.extend(extra.clone()); } if side == "two_sides" { p["thickness"] = json!(0.5); } run(&mut s, "solid.extrude", p); close(volume(&mut s), area * 10.0); assert_eq!(body_count(&mut s), 1, "{side}"); } } #[test] fn the_thickness_is_a_parameter_and_the_wall_survives_reopening() { let mut s = Session::default(); rectangle(&mut s); let r = run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 10, "name": "Wall"})); assert!(r["created_bodies"].as_array().is_some_and(|b| b.len() == 1)); close(volume(&mut s), 240.0 * 10.0); let f = s.doc.features.iter().find(|f| f.name == "Wall").cloned().unwrap(); let t = f.param_names.iter().zip(f.kind.inputs()).find(|(_, (label, _, _))| *label == "Thickness").map(|(n, _)| n.clone()).unwrap(); run(&mut s, "parameters.change", json!({"name": t, "expression": "4"})); close(volume(&mut s), (44.0 * 24.0 - 36.0 * 16.0) * 10.0); let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; reopened.refresh(); close(volume(&mut reopened), (44.0 * 24.0 - 36.0 * 16.0) * 10.0); // A plain extrude of the same sketch fills the rectangle. let mut s = Session::default(); rectangle(&mut s); run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10})); close(volume(&mut s), 800.0 * 10.0); } #[test] fn chosen_curves_make_an_open_wall_that_joins_and_cuts() { // Three sides of the rectangle: a U, inside toward the open side's middle. let mut s = Session::default(); let sides = rectangle(&mut s); let u = [&sides[0], &sides[1], &sides[2]]; run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "curves": u, "thickness": 2, "side": "inside", "distance": 5})); // 40 × 2 twice and 20 × 2, less the two 2 × 2 corners they share. close(volume(&mut s), (80.0 + 40.0 + 80.0 - 8.0) * 5.0); // Cut a groove 3 deep along the whole outline into a block under it. let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"})); rectangle(&mut s); run( &mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 3, "direction": "negative", "operation": "cut"}), ); close(volume(&mut s), 15000.0 - 240.0 * 3.0); assert_eq!(body_count(&mut s), 1); // And join a rim on top of it. let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"})); rectangle(&mut s); run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 4, "operation": "auto"})); close(volume(&mut s), 15000.0 + 240.0 * 4.0); assert_eq!(body_count(&mut s), 1); } #[test] fn a_ring_and_a_cross_of_walls_are_one_body() { let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Target"})); run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20})); run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]})); run(&mut s, "sketch.line", json!({"points": [[0, -20], [0, 20]]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Target", "thin": true, "thickness": 1, "distance": 5})); assert_eq!(body_count(&mut s), 1); // The ring, plus the bars inside it less where they cross and where they run into it. let ring = std::f64::consts::PI * (20.5f64.powi(2) - 19.5f64.powi(2)); let v = volume(&mut s); assert!(v > (ring + 2.0 * 38.0 - 1.0) * 5.0 && v < (ring + 2.0 * 40.0) * 5.0, "{v}"); } #[test] fn bad_thin_walls_are_refused_and_change_nothing() { let mut s = Session::default(); rectangle(&mut s); let before = s.doc.to_json(); for (p, says) in [ (json!({"thin": true, "thickness": 2, "distance": 5, "curves": ["nope"]}), "no curve"), (json!({"thin": true, "distance": 5}), "thickness"), (json!({"thin": true, "thickness": 2, "distance": 5, "side": "two_sides"}), "thickness2"), (json!({"thin": true, "thickness": 2, "thickness2": 1, "distance": 5}), "two_sides"), (json!({"thin": true, "thickness": 2, "distance": 5, "side": "sideways"}), "unknown side"), (json!({"thin": true, "thickness": 2, "distance": 5, "profiles": [0]}), "profiles"), (json!({"thin": true, "thickness": 0, "distance": 5}), "thickness"), (json!({"thin": true, "thickness": -1, "distance": 5}), "thickness"), (json!({"thin": "yes", "thickness": 1, "distance": 5}), "`thin`"), ] { let mut p = p; p["sketch"] = json!("Outline"); let e = s.execute("solid.extrude", &p).err().map(|e| e.to_string()).unwrap_or_default(); assert!(e.contains(says), "{p}: {e}"); assert_eq!(s.doc.to_json(), before); } // A wall thicker than an arc it follows bends. let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"})); run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1})); run(&mut s, "sketch.finish", json!({})); let e = s.execute("solid.extrude", &json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "inside", "distance": 5})); assert!(e.is_err_and(|e| e.to_string().contains("thicker"))); run(&mut s, "solid.extrude", json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "outside", "distance": 5})); // Measured from the tessellation, within its 1e-4. let (v, exact) = (volume(&mut s), std::f64::consts::PI * (16.0 - 1.0) * 5.0); assert!((v - exact).abs() < 1e-4 * exact, "{v} vs {exact}"); } #[test] fn hostile_thin_params_never_panic() { let keys = ["curves", "thickness", "thickness2", "side", "distance", "taper", "direction", "to", "operation"]; for v in crate::tests::hostile_values().into_iter().chain([json!(1e9), json!(1e-9), json!("1e6 m"), json!(["l1", "l1", "l1"])]) { for k in keys { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); rectangle(&mut s); let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "two_sides", "thickness2": 1, "distance": 5}); p[k] = v.clone(); if let Err(crate::EngineError::Internal(id, msg)) = s.execute("solid.extrude", &p) { panic!("{id} {p}: {msg}"); } } } } // #1519: sketches on a thin extrude's caps. A thin wall's caps are bounded by offset curves the // sketch doesn't have, so they are certified by the curves the wall follows: the signature the // solid caps had when the wall follows the whole profile. fn attachment(s: &Session, sketch: &str) -> solidcraft_doc::FaceAttachmentStatus { let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap_or_default(); s.placed_document().sketch_face_attachment(id, &s.model) } fn attached(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option) { match attachment(s, sketch) { solidcraft_doc::FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate), other => panic!("{sketch}: {other:?}"), } } fn same_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane) { let near = |p: solidcraft_geom::Vec3, q: solidcraft_geom::Vec3| (p - q).len() < 1e-9; assert!(near(a.origin, b.origin) && near(a.x, b.x) && near(a.y, b.y), "{a:?} != {b:?}"); } fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) { run(s, "sketch.create", json!({"plane": {"face": at}, "name": name})); run(s, "sketch.finish", json!({})); } fn is_extrude_cap(certificate: &Option) -> bool { matches!(certificate, Some(solidcraft_doc::config::FaceCertificate::ExtrudeCap { .. })) } #[test] fn sketches_on_a_solid_extrudes_caps_keep_their_faces_when_it_is_made_thin_and_solid_again() { for side in ["center", "inside", "outside"] { let mut s = Session::default(); let sides = rectangle(&mut s); run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10, "name": "Wall"})); // On the rim of the wall whichever side it lies on (the rectangle starts at (5, 5)). sketch_on(&mut s, [5.5, 5.5, 10.0], "Top"); sketch_on(&mut s, [5.5, 5.5, 0.0], "Bottom"); let (top, _) = attached(&s, "Top"); let (bottom, _) = attached(&s, "Bottom"); run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"thin": {"curves": sides, "thickness": "1", "side": side}}})); no_errors(&s, side); let (thin_top, certificate) = attached(&s, "Top"); assert!(is_extrude_cap(&certificate), "{side}: {certificate:?}"); same_plane(&thin_top, &top); same_plane(&attached(&s, "Bottom").0, &bottom); // The sketch follows the thin wall's height, and keeps its face through a save. run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"extent": {"distance": "25"}}})); assert!((attached(&s, "Top").0.origin.z - 25.0).abs() < 1e-9, "{side}"); let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() }; reopened.refresh(); no_errors(&reopened, side); assert!((attached(&reopened, "Top").0.origin.z - 25.0).abs() < 1e-9, "{side}"); // And solid again. run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"thin": null, "extent": {"distance": "10"}}})); no_errors(&s, side); same_plane(&attached(&s, "Top").0, &top); same_plane(&attached(&s, "Bottom").0, &bottom); } } #[test] fn a_sketch_on_a_thin_cap_is_certified_and_keeps_its_face_when_the_extrude_is_made_solid() { let mut s = Session::default(); rectangle(&mut s); run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "inside", "distance": 10, "name": "Wall"})); sketch_on(&mut s, [6.0, 6.0, 10.0], "Top"); let (top, certificate) = attached(&s, "Top"); assert!(is_extrude_cap(&certificate), "{certificate:?}"); run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"thin": null}})); no_errors(&s, "solid"); same_plane(&attached(&s, "Top").0, &top); } #[test] fn a_thin_wall_along_part_of_the_profile_does_not_take_over_its_cap() { // A wall along two sides is another wall than the rectangle's: the sketch reports its lost // face instead of moving onto the new one. let mut s = Session::default(); let sides = rectangle(&mut s); run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10, "name": "Wall"})); sketch_on(&mut s, [6.0, 6.0, 10.0], "Top"); let _ = s.execute("timeline.edit", &json!({"feature": "Wall", "set": {"thin": {"curves": [&sides[0], &sides[1]], "thickness": "2"}}})); assert!(matches!(attachment(&s, "Top"), solidcraft_doc::FaceAttachmentStatus::Lost { .. }), "{:?}", attachment(&s, "Top")); } #[test] fn the_caps_of_walls_apart_attach_by_their_history_names() { // Two rectangles make two walls: their caps aren't certified (one signature would name // both), so a sketch on one attaches by its exact history name, as before. let mut s = Session::default(); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, 0], "p1": [30, 10]})); run(&mut s, "sketch.finish", json!({})); run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 1, "side": "inside", "distance": 5})); sketch_on(&mut s, [0.5, 0.5, 5.0], "Top"); let (_, certificate) = attached(&s, "Top"); assert!(matches!(certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage)), "{certificate:?}"); } fn no_errors(s: &Session, context: &str) { assert!( s.model.results.iter().all(|r| r.error.is_none()), "{context}: {:?}", s.model.results.iter().filter_map(|r| r.error.as_ref()).collect::>() ); }