Thin extrude caps are certified by the curves the wall follows, so a sketch attached to a cap survives switching between solid and thin (#1519). Co-Authored-By: Philipp Bischoff <[email protected]> Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
285 lines
14 KiB
Rust
285 lines
14 KiB
Rust
//! 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<String> {
|
||
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<solidcraft_doc::config::FaceCertificate>) {
|
||
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<solidcraft_doc::config::FaceCertificate>) -> 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::<Vec<_>>()
|
||
);
|
||
}
|